diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1485ae8..92bc7ac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,27 @@ jobs: bash scripts/mining-amd/install-configs.sh --force cmp scripts/mining-amd/poworker.amd.ini.example target/release/poworker.config.ini + # The crate that holds the pool wallet and signs the payouts. It was built + # for the first time at TAG time, in release.yml, so every defect in it had + # to be found by a human reading it. Its own suite covers the money rules: + # the coinbase hold-back, the payout verdicts, the chain-identity gate, the + # refusal to run on an unreadable ledger, and the deployment files. + - name: Build and test the HBIT payout pool + run: | + cargo build --locked --release -p hbit-pool + cargo test --locked -p hbit-pool + + # --no-deps is load-bearing, not tidiness. Clippy lints path dependencies + # by default, and `-p hbit-pool` alone reports 55 findings from `field` and + # `sys` and 0 from this crate - including one deny-by-default lint + # (inherent_to_string_shadow_display on Amount) that fails the run outright. + # Those are consensus crates: fixing that lint would remove a method used + # across the tree and change what Amount renders, which is not something a + # pool change may do. --no-deps lints exactly the crate that signs payouts, + # which is clean at -D warnings and will not go quietly red. + - name: Lint the payout pool + run: cargo clippy --locked --no-deps -p hbit-pool --all-targets -- -D warnings + - name: Build and test miner panel run: | cargo build --locked --release -p miner-panel diff --git a/.github/workflows/release-node.yml b/.github/workflows/release-node.yml new file mode 100644 index 00000000..ba242840 --- /dev/null +++ b/.github/workflows/release-node.yml @@ -0,0 +1,205 @@ +name: Release (HPAY-compatible fullnode) + +on: + push: + tags: + - "node-v*" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + RELEASE_REF_NAME: ${{ github.ref_name }} + +jobs: + build-windows: + name: Fullnode (Windows x64) + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch + + - name: Cache cargo + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: release-node-windows + + - name: Build CPU-only fullnode + run: cargo build --locked --release --bin hacash + + - name: Verify fullnode capabilities + run: cargo test --locked -p app node_capabilities_tests + + - name: Package standalone node + shell: pwsh + run: | + $version = "manual" + if ($env:GITHUB_REF_TYPE -eq "tag") { + $candidate = $env:RELEASE_REF_NAME + if ($candidate -notmatch '^node-v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-hpay\.(?:0|[1-9][0-9]*))?$') { + throw "Refusing unsafe node release tag: $candidate" + } + $version = $candidate + } + & "$env:GITHUB_WORKSPACE\scripts\pack-node-release.ps1" -Version $version + + - name: Verify Windows node package boundary + shell: pwsh + run: | + $archives = @(Get-ChildItem "$env:GITHUB_WORKSPACE\dist-node" -Filter "*.zip" -File) + if ($archives.Count -ne 1) { throw "Expected one standalone node ZIP" } + $archive = $archives[0] + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive.FullName).Hash.ToLowerInvariant() + $expected = [IO.File]::ReadAllText("$($archive.FullName).sha256").Trim() + if ($expected -ne "$actual $($archive.Name)") { throw "Node ZIP checksum mismatch" } + $extract = Join-Path $env:RUNNER_TEMP "node-package" + Expand-Archive -LiteralPath $archive.FullName -DestinationPath $extract + $root = @(Get-ChildItem -LiteralPath $extract -Directory) + if ($root.Count -ne 1) { throw "Node ZIP must contain one package root" } + $files = @(Get-ChildItem -LiteralPath $root[0].FullName -File | Select-Object -ExpandProperty Name) + $expectedFiles = @("hacash.exe", "hacash.config.ini.example", "README.txt", "SOURCE-COMMIT.txt", "VERSION.txt") + if ((Compare-Object $files $expectedFiles).Count -ne 0) { throw "Unexpected standalone node package contents: $($files -join ', ')" } + + - name: Upload Windows node artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hpay-compatible-fullnode-windows-x64 + path: | + dist-node/*.zip + dist-node/*.zip.sha256 + if-no-files-found: error + retention-days: 30 + + build-linux: + name: Fullnode (Linux x86_64) + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch + + - name: Cache cargo + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: release-node-linux + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + + - name: Build CPU-only fullnode + run: cargo build --locked --release --bin hacash + + - name: Verify fullnode capabilities + run: cargo test --locked -p app node_capabilities_tests + + - name: Verify linked libraries + run: | + test -x target/release/hacash + if ldd target/release/hacash | grep -q 'not found'; then + ldd target/release/hacash + exit 1 + fi + if ldd target/release/hacash | grep -qi opencl; then + echo "Standalone fullnode must not depend on OpenCL" + exit 1 + fi + + - name: Package and verify Linux node + run: | + version="manual" + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + candidate="$RELEASE_REF_NAME" + if [[ ! "$candidate" =~ ^node-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-hpay\.(0|[1-9][0-9]*))?$ ]]; then + echo "Refusing unsafe node release tag: $candidate" + exit 1 + fi + version="$candidate" + fi + bash scripts/pack-node-release-linux.sh "$version" + archives=(dist-node/*.tar.gz) + test "${#archives[@]}" -eq 1 + tar -tzf "${archives[0]}" >/dev/null + (cd dist-node && sha256sum -c ./*.tar.gz.sha256) + root=dist-node/hpay-compatible-hacash-fullnode-linux-x86_64 + test -x "$root/hacash" + mapfile -t files < <(find "$root" -maxdepth 1 -type f -printf '%f\n' | sort) + expected=(README.txt SOURCE-COMMIT.txt VERSION.txt hacash hacash.config.ini.example) + [[ "${files[*]}" == "${expected[*]}" ]] + + - name: Upload Linux node artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hpay-compatible-fullnode-linux-x86_64 + path: | + dist-node/*.tar.gz + dist-node/*.tar.gz.sha256 + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish standalone fullnode release + if: startsWith(github.ref, 'refs/tags/node-v') + needs: [build-windows, build-linux] + runs-on: ubuntu-22.04 + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Validate release tag + run: | + if [[ ! "$RELEASE_REF_NAME" =~ ^node-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-hpay\.(0|[1-9][0-9]*))?$ ]]; then + echo "Refusing unsafe node release tag: $RELEASE_REF_NAME" + exit 1 + fi + + - name: Download node artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + path: release-assets + merge-multiple: true + + - name: Attest node artifacts + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: | + release-assets/*.zip + release-assets/*.tar.gz + + - name: Publish node release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ github.ref_name }} + name: HPAY-compatible Hacash Full Node ${{ github.ref_name }} + make_latest: false + files: release-assets/* + generate_release_notes: true + body: | + ## Standalone Hacash full node for HPAY Fast Pay and L2 hubs + + This is the same CPU-only `hacash` fullnode binary used by the full + miner package. It does not change Hacash consensus and it does not + include mining workers, a pool, a wallet or private keys. + + Verify the GitHub build attestation before running an archive: + + ``` + gh attestation verify --repo ${{ github.repository }} + ``` + + The included mainnet configuration binds the HTTP API to + `127.0.0.1:8080` and keeps both miners disabled. diff --git a/.github/workflows/release-pool.yml b/.github/workflows/release-pool.yml new file mode 100644 index 00000000..ba0ad827 --- /dev/null +++ b/.github/workflows/release-pool.yml @@ -0,0 +1,232 @@ +name: Release HBIT pool + +# The pool ships on its OWN tags, `pool-v*`, and never on a miner tag. +# +# They are different products for different people. The miner is a Windows +# desktop program an individual runs on their gaming PC; the pool is Linux +# server software an operator runs on a VPS beside their own full node, holding +# other people's money. An operator should not wait for a miner release to get a +# fix to the thing that pays their miners, and nobody should re-download a miner +# because a pool changed. +# +# `v*` and `pool-v*` cannot collide: GitHub matches the whole ref, and a tag +# beginning `pool-` never matches a pattern beginning `v`. +on: + push: + tags: + - "pool-v*" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + RELEASE_REF_NAME: ${{ github.ref_name }} + +jobs: + build: + name: Build and package (Ubuntu x86_64) + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch + + - name: Cache cargo + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: release-pool-linux + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev libudev-dev + + - name: Build the pool and the node it needs + run: | + cargo build --locked --release -p hbit-pool + # The full node ships WITH the pool. The pool gets its templates from a + # node and submits blocks to one, so an archive without it installs + # something that cannot start. + cargo build --locked --release --bin hacash + + - name: Test the pool + run: | + cargo test --locked -p hbit-pool + cargo test --locked -p basis --lib + cargo test --locked -p sys + + # The gate that guards the money path, scoped to this crate. --no-deps is + # load-bearing: clippy lints path dependencies by default, and `field` + # carries a deny-by-default lint that is consensus code and not ours to + # change. + - name: Lint the pool + run: cargo clippy --locked --no-deps -p hbit-pool --all-targets -- -D warnings + + - name: Package + run: | + version="manual" + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + candidate="$RELEASE_REF_NAME" + if [[ ! "$candidate" =~ ^pool-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "Refusing unsafe or non-SemVer pool tag: $candidate" + exit 1 + fi + version="$candidate" + fi + + # The archive name keeps the prefix the download page looks for. Only + # the version part changes, so an existing page keeps working. + pooldir="dist/hbit-pool-linux-x86_64" + rm -rf "$pooldir" && mkdir -p "$pooldir/systemd" + cp target/release/hbit-pool-server target/release/hbit-pool-payout "$pooldir/" + cp target/release/hacash "$pooldir/" + cp deploy/node/hacash.config.ini "$pooldir/hacash.config.ini.example" + cp deploy/systemd/hacash-node.service deploy/systemd/hbit-pool.service "$pooldir/systemd/" + cp deploy/hbit-wait-for-node.sh "$pooldir/" + cp deploy/README.md "$pooldir/DEPLOY.md" + cp docs/POOL-OPERATOR.md docs/POOL-README.md "$pooldir/" + # Kept under its repository name: POOL-OPERATOR.md links into it with a + # relative path, and the archive is flat, so renaming the directory + # here silently breaks that link for everyone who reads the shipped copy. + cp -r docs/hbit-v2 "$pooldir/hbit-v2" + cp scripts/hbit-vps-setup.sh "$pooldir/SETUP-POOL.sh" + chmod u+x "$pooldir"/hbit-pool-server "$pooldir"/hbit-pool-payout \ + "$pooldir"/hacash "$pooldir"/hbit-wait-for-node.sh "$pooldir"/SETUP-POOL.sh + printf '%s' "$version" > "$pooldir/VERSION.txt" + + name="hbit-pool-linux-x86_64-$version.tar.gz" + tar -czf "dist/$name" -C dist hbit-pool-linux-x86_64 + (cd dist && sha256sum "$name" > "$name.sha256") + test -s "dist/$name" + + - name: Verify the archive is deployable + run: | + # Every claim the operator docs make about this archive, checked here + # rather than discovered on somebody's VPS. + d=dist/hbit-pool-linux-x86_64 + for f in hbit-pool-server hbit-pool-payout hacash hbit-wait-for-node.sh SETUP-POOL.sh; do + test -x "$d/$f" || { echo "missing or not executable: $f"; exit 1; } + done + for f in hacash.config.ini.example DEPLOY.md POOL-OPERATOR.md POOL-README.md \ + hbit-v2/MAINNET-SAFETY.md \ + systemd/hacash-node.service systemd/hbit-pool.service; do + test -f "$d/$f" || { echo "missing: $f"; exit 1; } + done + # Ships with NO wallet, NO address and NO config: an archive that + # carried any of those would pay a stranger by default. + test -z "$(find "$d" -name '*.key' -o -name '*wallet*' -o -name '*.state.json')" \ + || { echo "the archive carries wallet material"; exit 1; } + grep -Eq '^[[:space:]]*reward[[:space:]]*=[[:space:]]*$' "$d/hacash.config.ini.example" \ + || { echo "the shipped node config must have an EMPTY reward"; exit 1; } + ./"$d"/hbit-pool-server --help > /dev/null + ./"$d"/hbit-pool-payout --help > /dev/null + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hbit-pool-linux-x86_64 + path: | + dist/*.tar.gz + dist/*.sha256 + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/pool-v') + needs: [build] + runs-on: ubuntu-22.04 + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Download artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + path: release-assets + merge-multiple: true + + # Runs BEFORE the release is created: a package that cannot be attested + # must never reach an operator. The .sha256 beside the archive only detects + # a corrupt download; the attestation is signed by GitHub and is the only + # tamper-evident check. + - name: Attest provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: release-assets/*.tar.gz + + # The tag is `pool-v0.2.0`; the title says `HBIT pool v0.2.0`. Interpolating + # the ref straight into the name produced "HBIT pool pool-v0.2.0". + - name: Title + id: title + run: echo "name=HBIT pool ${GITHUB_REF_NAME#pool-}" >> "$GITHUB_OUTPUT" + + - name: Publish + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ steps.title.outputs.name }} + # A pool release must NOT become the repository's "latest". That URL + # can only point at one release, and github.com//releases/latest + # is where a visitor and the download page's fallback links expect the + # MINER. The first pool release took it, and for a few minutes anyone + # who clicked "Download Full package" while the API was unreachable + # landed on server software instead. + make_latest: false + files: release-assets/* + body: | + ## HBIT pool - Linux x86_64 - By Mosky + + Server software for a pool operator. This is **not** the miner: it + runs on a VPS or a mini PC beside your own full node, and it pays + miners with PPLNS. Individual miners want the + [miner releases](https://github.com/${{ github.repository }}/releases?q=v&expanded=true) + instead. + + The pool is versioned and released on its own, so a fix to the thing + that holds your miners' money does not wait for a miner release. + + ### Install + + ``` + tar xzf hbit-pool-linux-x86_64-*.tar.gz + cd hbit-pool-linux-x86_64 + ./SETUP-POOL.sh + ``` + + `SETUP-POOL.sh` checks what it can before you start: that the node + config exists and its `reward` is yours, that the node API is on + loopback or has a token, and that nothing in the archive is going to + pay a stranger. Read `DEPLOY.md` for the systemd and Docker paths and + `POOL-OPERATOR.md` for the runbook. + + ### Check the download before you run it + + This software holds a wallet and signs payouts. Verify the build + provenance first: it is the only check that detects tampering, + because the signature is produced by GitHub and not by whoever serves + the file. + + ``` + gh attestation verify --repo ${{ github.repository }} + ``` + + The `.sha256` file detects a truncated or corrupted download only. It + is NOT tamper protection: it is published from the same place as the + archive. + + ### What is NOT proven + + The pool has run against a live mainnet node and accepted real + shares. It has never found a block on mainnet, so block discovery + through hold-back, maturity and payout has not executed against the + real chain. `hbit-v2/` in the archive states what is and is not + implemented, in the same words. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2acbdc1f..8b0d65cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,10 +110,12 @@ jobs: - name: Build public free-IP pool (hac-pool) run: cargo build --locked --release -p miner-pool - # HBIT payout pool: hbit-pool-server, hbit-settle-spike and - # hbit-pool-payout hold the pool wallet and compute real payouts, so they - # must compile in the release pipeline even though they are not packaged - # (operators self-build). + # HBIT payout pool: built and tested here as a GATE, never packaged here. + # It ships on its own `pool-v*` tags through release-pool.yml, because it + # is server software for a different audience on a different release + # rhythm - an operator holding other people's money should not wait for a + # miner release. Building it here still stops a miner change from breaking + # the crate that signs payouts. - name: Build HBIT payout pool server (hbit-pool) run: cargo build --locked --release -p hbit-pool @@ -140,43 +142,12 @@ jobs: } & "$env:GITHUB_WORKSPACE\scripts\pack-release.ps1" -Version $version - - name: Package HBIT pool (Windows) - shell: pwsh - run: | - # Packaged APART from the miner on purpose. This is server software an - # operator runs beside their own full node; bundling a wallet-holding - # binary into the miner archive would put it on every gaming PC that - # downloads this. - # - # Ships with NO wallet, NO config and NO address. The operator supplies - # all three and the pool refuses to start without them, so nothing here - # can pay a stranger by default. - $version = "manual" - if ($env:GITHUB_REF_TYPE -eq "tag") { $version = $env:RELEASE_REF_NAME } - $ws = $env:GITHUB_WORKSPACE - $pooldir = Join-Path $ws "dist/hbit-pool-windows-x64" - if (Test-Path $pooldir) { Remove-Item $pooldir -Recurse -Force } - New-Item -ItemType Directory -Force -Path $pooldir | Out-Null - foreach ($f in @("hbit-pool-server.exe", "hbit-pool-payout.exe")) { - Copy-Item (Join-Path $ws "target/release/$f") $pooldir - } - foreach ($f in @("POOL-OPERATOR.md", "POOL-README.md")) { - Copy-Item (Join-Path $ws "docs/$f") $pooldir - } - [IO.File]::WriteAllText((Join-Path $pooldir "VERSION.txt"), $version) - $name = if ($version -like "v*") { "hbit-pool-windows-x64-$version.zip" } else { "hbit-pool-windows-x64.zip" } - $poolzip = Join-Path $ws "dist/$name" - Compress-Archive -Path $pooldir -DestinationPath $poolzip -Force - $h = (Get-FileHash -Algorithm SHA256 -LiteralPath $poolzip).Hash.ToLowerInvariant() - [IO.File]::WriteAllText("$poolzip.sha256", "$h $name") - if ((Get-Item $poolzip).Length -lt 1000) { throw "pool zip looks empty" } - Write-Host "packaged $name" - - name: Validate Windows release ZIPs shell: pwsh run: | - # Miner ZIPs only. The pool ships its own archive beside these, and - # counting every zip would fail this build for the wrong reason the + # Miner ZIPs only, and now that is all this release produces: the pool + # moved to its own `pool-v*` tags. Matching by name rather than + # counting every zip keeps this from failing for the wrong reason the # moment another package is added. $zips = @(Get-ChildItem "$env:GITHUB_WORKSPACE\dist" -Filter "hacash-miner-*.zip" -File) if ($zips.Count -ne 2) { throw "Expected 2 Windows miner ZIPs, found $($zips.Count)" } @@ -324,38 +295,6 @@ jobs: fi bash scripts/pack-release-linux.sh "$version" - # HBIT pool, packaged apart from the miner for the same reason as on - # Windows: it is server software that holds a wallet, and it has no - # business inside an archive aimed at people's gaming PCs. Ships with - # no wallet, no config and no address; the pool refuses to start - # without them. - pooldir="dist/hbit-pool-linux-x86_64" - rm -rf "$pooldir" && mkdir -p "$pooldir/systemd" - cp target/release/hbit-pool-server target/release/hbit-pool-payout "$pooldir/" - # The full node ships WITH the pool. Without it the archive is not - # deployable at all: the pool gets its templates from a node and submits - # blocks to one, so an operator who downloaded only this would install - # something that cannot start. The first version of this package left it - # out, which is a packaging bug and not a documentation problem. - cp target/release/hacash "$pooldir/" - cp deploy/node/hacash.config.ini "$pooldir/hacash.config.ini.example" - cp deploy/systemd/hacash-node.service deploy/systemd/hbit-pool.service "$pooldir/systemd/" - cp deploy/hbit-wait-for-node.sh "$pooldir/" - cp deploy/README.md "$pooldir/DEPLOY.md" - cp docs/POOL-OPERATOR.md docs/POOL-README.md "$pooldir/" - cp scripts/hbit-vps-setup.sh "$pooldir/SETUP-POOL.sh" - chmod u+x "$pooldir"/hbit-pool-server "$pooldir"/hbit-pool-payout \ - "$pooldir"/hacash "$pooldir"/hbit-wait-for-node.sh "$pooldir"/SETUP-POOL.sh - printf '%s' "$version" > "$pooldir/VERSION.txt" - if [[ "$version" == v* ]]; then - poolname="hbit-pool-linux-x86_64-$version.tar.gz" - else - poolname="hbit-pool-linux-x86_64.tar.gz" - fi - tar -czf "dist/$poolname" -C dist hbit-pool-linux-x86_64 - (cd dist && sha256sum "$poolname" > "$poolname.sha256") - test -s "dist/$poolname" - archives=(dist/hacash-miner-*-linux-x86_64*.tar.gz) test "${#archives[@]}" -eq 2 for archive in "${archives[@]}"; do @@ -436,10 +375,20 @@ jobs: release-assets/*.zip release-assets/*.tar.gz + # Say WHICH product this is. With no name the action falls back to the bare + # tag, and this repository now publishes two things: a visitor scanning the + # list saw "HBIT pool v0.2.2" beside a bare "v0.5.9" and had nothing to tell + # them the second one was the miner. The wording matches the heading the + # release body already carries. + - name: Title + id: title + run: echo "name=HAC Miner ${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + - name: Publish release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ github.ref_name }} + name: ${{ steps.title.outputs.name }} files: release-assets/* generate_release_notes: true body: | diff --git a/Cargo.lock b/Cargo.lock index a5f08deb..0d68fc43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,7 @@ dependencies = [ "serde_json", "sys", "testkit", + "vm", "x16rs", "x16rs-cuda", "x16rs-sys", @@ -1115,16 +1116,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1138,7 +1129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -1151,7 +1142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] @@ -1669,7 +1660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1690,11 +1681,10 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2129,7 +2119,7 @@ dependencies = [ [[package]] name = "hacash" -version = "0.5.5" +version = "0.5.10" dependencies = [ "app", "basis", @@ -2165,7 +2155,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hbit-pool" -version = "0.1.0" +version = "0.2.2" dependencies = [ "aes-gcm", "argon2", @@ -3163,7 +3153,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3913,7 +3903,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4197,7 +4187,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4797,7 +4787,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5503,15 +5493,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation 0.10.1", "jni", "log", "ndk-context", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", "web-sys", @@ -5648,7 +5638,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5886,7 +5876,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases 0.2.1", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", diff --git a/Cargo.toml b/Cargo.toml index f8b06c3b..3bdb0294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,20 @@ [package] name = "hacash" default-run = "hacash" -version = "0.5.5" +# The MINER's version. It ships on `v*` tags and the pool ships on its own +# `pool-v*` tags, because they are different products for different people: see +# hbit-pool/Cargo.toml. A pool fix must never make anyone re-download a miner, +# and a miner fix must never wait for one. +# +# 0.5.9 carries two fixes for anyone mining at a pool. The day's earnings were +# divided by the target the RESULT was measured against, which at a pool is the +# share target, so every pooled rig read as 100% of the network and claimed a +# full block reward every block; it now reads the difficulty out of the header it +# is already hashing, which no pool can fake because those are the bytes the node +# checks. And `connect = https://host` was pasted into `http://{}`, so a pool +# reached over TLS could not be reached at all and plaintext was not something an +# operator could decline. +version = "0.5.10" edition = "2024" [workspace] diff --git a/README-NODE.txt b/README-NODE.txt new file mode 100644 index 00000000..ad37ee47 --- /dev/null +++ b/README-NODE.txt @@ -0,0 +1,46 @@ +HPAY-compatible Hacash Full Node +================================ + +This package is for people who want to run a Hacash full node for HPAY Fast Pay +or an L2 hub without installing the miner package. + +It does not change the Hacash consensus protocol. It uses the HVM and contract +features already provided by the Istanbul runtime. HPAY checks the node's +/query/capabilities response and fails closed when a required API or runtime +feature is unavailable. + +SECURITY FIRST +-------------- +1. Verify the GitHub build attestation before running the archive: + + gh attestation verify --repo Moskyera/fullnodedev + +2. The SHA-256 file detects accidental corruption only. It is not a signature. +3. Keep the HTTP API bound to 127.0.0.1 unless you have configured a firewall, + TLS reverse proxy and authentication for a deliberate remote deployment. +4. Never add a reward address or wallet password unless you intentionally + enable mining. Mining is disabled in the included example configuration. +5. Back up the node data directory before replacing an existing binary. + +WINDOWS +------- +1. Extract the ZIP into a new folder. +2. Copy hacash.config.ini.example to hacash.config.ini. +3. Run hacash.exe. + +LINUX +----- +1. Extract the archive into a new folder. +2. Copy hacash.config.ini.example to hacash.config.ini. +3. Make the binary executable if required: chmod +x hacash +4. Run ./hacash. + +The included configuration joins Hacash mainnet, enables the local HTTP API on +127.0.0.1:8080 and keeps HAC and HACD mining disabled. Wait for synchronization +to complete before connecting an HPAY L2 hub. + +PACKAGE BOUNDARY +---------------- +This archive contains the full node only. It deliberately excludes poworker, +diaworker, miner-panel, mining kernels, pool software and wallet software. + diff --git a/README.md b/README.md index a6ed6a47..75026403 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,19 @@ checksum is **not** proof the file is genuine; the attestation is. | **HAC** | `poworker` | OpenCL (AMD/NVIDIA/Intel) and/or **CUDA** (NVIDIA) | | **HACD** | `diaworker` | CPU only (no OpenCL/CUDA) | +- **HACD nonce rate jumps, and the "best so far" string gets weaker. Both are expected.** + `diaworker` now runs the sha3-only half of the difficulty check *before* the 17+ + rounds of x16rs. A nonce that fails it can never mint a diamond for any x16rs + hash, so its rounds are skipped: measured **8.3x** at diamond 133,700, **26x** at + 210,000, **60x** at 300,000, and the gain keeps rising with the diamond number. + Below 42,000 the check passes everything and costs nothing. The consequence you + see is that the two diamond strings in the status line (`... | AAAA -> BBBB.`) + are now drawn only from the ~11% of nonces that got past the check, so they show + fewer leading zeros than they used to. Those strings are **display only**; the + set of diamonds actually found and submitted is unchanged, which is what + `a_failing_gate_forbids_every_possible_x16rs_hash` (app/src/hash_util.rs) and + `the_prefilter_never_skips_a_nonce_that_could_have_minted` (app/src/diaworker.rs) + assert on every build. - OpenCL: **[docs/MINING-AMD.md](docs/MINING-AMD.md)**, **[docs/MINING-LINUX.md](docs/MINING-LINUX.md)** - CUDA: **[docs/MINING-NVIDIA-CUDA.md](docs/MINING-NVIDIA-CUDA.md)** (T4 Colab validated) - Public free-IP pool: **`hac-pool`** - **[docs/PUBLIC-POOL.md](docs/PUBLIC-POOL.md)** diff --git a/SETUP-MINER.bat b/SETUP-MINER.bat index 568b274c..50106ee6 100644 --- a/SETUP-MINER.bat +++ b/SETUP-MINER.bat @@ -133,12 +133,14 @@ exit /b 0 :write_default_diaworker_ini ( echo connect = 127.0.0.1:8080 - echo supervene = 4 + echo ; 0 = fit this machine: all logical CPUs but two. A number is obeyed exactly. + echo supervene = 0 echo. echo [efficiency] echo mode = profit echo cpu_watts_per_thread = 8 - echo dynamic_supervene = true + echo ; HACD has no GPU, so there is no GPU/CPU ratio to rebalance against. + echo dynamic_supervene = false echo supervene_min = 1 echo supervene_max = 0 echo benchmark_seconds = 0 diff --git a/SETUP.bat b/SETUP.bat index 1b6b9f3b..b1983910 100644 --- a/SETUP.bat +++ b/SETUP.bat @@ -218,12 +218,14 @@ exit /b 0 :write_default_diaworker_ini ( echo connect = 127.0.0.1:8080 - echo supervene = 4 + echo ; 0 = fit this machine: all logical CPUs but two. A number is obeyed exactly. + echo supervene = 0 echo. echo [efficiency] echo mode = profit echo cpu_watts_per_thread = 8 - echo dynamic_supervene = true + echo ; HACD has no GPU, so there is no GPU/CPU ratio to rebalance against. + echo dynamic_supervene = false echo supervene_min = 1 echo supervene_max = 0 echo benchmark_seconds = 0 diff --git a/app/Cargo.toml b/app/Cargo.toml index 9dfd8880..3e177083 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -10,6 +10,7 @@ field = {path = "../field"} basis = {path = "../basis"} protocol = {path = "../protocol"} mint = {path = "../mint"} +vm = {path = "../vm"} hex = "0.4.3" axum = "0.7.9" serde = { version = "1.0.215", features = ["derive"] } diff --git a/app/src/autotune16.rs b/app/src/autotune16.rs new file mode 100644 index 00000000..f911264e --- /dev/null +++ b/app/src/autotune16.rs @@ -0,0 +1,5963 @@ +//! Auto-tune, measured on the workload the miner really runs. +//! +//! The tuner this replaces measured at height 1, i.e. x16rs repeat = 1, while +//! every rig on the live chain runs repeat = 16. Our own comment admitted it +//! ("measures at height = 1 to keep tuning fast"), so every launch shape the +//! panel has ever chosen was optimised for a workload nobody mines. At repeat 1 +//! the kernel runs one algorithm round per nonce instead of sixteen: the +//! per-hash cost profile, the register pressure and the memory behaviour are all +//! different, and so is the shape that wins. +//! +//! Four things are done differently here, and each one is a defect that was +//! costing the operator money rather than a refinement. +//! +//! 1. **repeat = 16, on a corpus that is frozen for the whole session.** +//! Every candidate hashes exactly the same multiset of (header, nonce) +//! pairs, in the same order. This matters more than it looks: x16rs picks +//! each round's algorithm from the previous round's output, and the sixteen +//! algorithms differ in cost by more than an order of magnitude, so a +//! candidate handed a different nonce window can draw a cheaper algorithm +//! mix and look faster while being slower. Same work, measure the time. Not +//! same time, different work. +//! +//! 2. **A coarse sweep, then the finalists re-run in alternating order.** +//! This card settles into a clock state for the life of a process and +//! drifts by ~2.6% between processes; within one process, back-to-back +//! measurements resolve ~0.3%. Running the finalists in the order A,B,C +//! then C,B,A and taking each one's median cancels the drift that would +//! otherwise hand the win to whichever candidate happened to run while the +//! card was cool. +//! +//! 3. **A thermal soak on the winner, run until it stops moving.** The old +//! final verification was 5 to 15 seconds, which is long enough to prove a +//! shape runs and far too short to prove it sustains. This one hashes the +//! corpus over and over while sampling temperature, board power, shader +//! clock and hashrate, and only accepts the shape once all four have been +//! flat across a window of passes. The time it took is reported, because on +//! an air-cooled card it is minutes, not seconds. +//! +//! 4. **p95 batch latency is a constraint, and stale work is priced.** A batch +//! is atomic: when the template changes, everything in flight is thrown +//! away. At a 300-second target block time a batch of L seconds throws away +//! L/2 seconds of work on average every time the job changes, so throughput +//! is discounted by that fraction before anything is compared, and a +//! candidate whose p95 batch exceeds the ceiling is refused outright no +//! matter how fast it hashes. +//! +//! Scoring is on measured watts wherever the card reports them (see +//! `gpu_temp_adl`), so Eco really does optimise hashes per joule and Profit +//! really does optimise net income, instead of both optimising a number derived +//! from a board-power constant typed into an ini. +//! +//! Consensus safety. x16rs is consensus, so a shape whose hashes differ from the +//! CPU reference in one byte mines invalid blocks. Every candidate is proved +//! against the CPU oracle at its own launch shape before its speed is allowed to +//! count, and the proof covers its entire batch window rather than a sample: see +//! `prove_shape`. That is a *shape* proof. It does not replace `x16rs_gate +//! equiv`, which is the *kernel* proof, and the tuner prints that command +//! whenever a kernel is newer than the last time somebody ran it. +//! +//! # The corpus is sized by the clock, not by the biggest candidate +//! +//! The shared corpus is what makes two launch shapes comparable, and its segment +//! has to be a common multiple of every candidate's batch. The first version of +//! this module planned one segment over the whole candidate universe and then +//! forced at least four of them, so the smallest amount of work a candidate +//! could be measured on was 24 to 36 times the largest candidate's batch, and +//! the largest batch scales with the card's work-group ceiling. On the one card +//! this was written for that is 75 M nonces, about 2.6 s. On a preset allowed +//! 2048 or 4096 work groups it is 2.4 to 4.8 G nonces, and since a soak has to +//! fit five passes inside 90 s, finishing a tune needed 107 to 215 MH/s. The +//! only x16rs repeat-16 rate ever measured here is 28.8 MH/s. Every card except +//! the one it was written on swept for tens of minutes and then reported that +//! the card "never settled", which was not true: the pass was simply longer than +//! the window it had to settle in. +//! +//! Three things now bound that, and the order matters: +//! +//! 1. **A shape whose batch cannot meet the latency ceiling is not measured.** +//! `score` already refuses any candidate whose p95 batch exceeds +//! `P95_BATCH_CEILING_MS`, so a batch that takes longer than that was never +//! going to be chosen. It was still being hashed, still being proved against +//! the CPU oracle over its entire window, and still forcing everyone else's +//! corpus segment up. The probe measures the card first, and shapes that +//! cannot fit the ceiling at that rate are named and dropped before a single +//! candidate is measured. +//! +//! 2. **The corpus segment has a cap in seconds, not in batches.** The cap is +//! the shorter of the pass the sweep can afford (`budget` split over the +//! passes it will make) and the pass the soak can settle on +//! (`max_soak_pass_seconds`). Shapes that would push the segment past it are +//! dropped, loudest first: the tuner prints each one and prints the +//! `benchmark_seconds` that would have kept them. That is the honest trade +//! and it is now the operator's to make: a short budget buys the 2x grid, a +//! long budget buys the 1.5x grid. +//! +//! 3. **`segments` starts at one.** It is chosen from the probe so that a pass +//! lands near the target, and it is never forced to four to satisfy a header +//! count; the header count follows the segments instead. +//! +//! The property that made the corpus shared is untouched: every candidate still +//! covers the identical (header_index, nonce) multiset in the identical order, +//! and `coverage_signature` proves it element by element rather than asserting +//! it in a comment. What changed is only how big that multiset is. +//! +//! # Both backends, one tuner +//! +//! This measures through [`crate::x16rs_gate`]'s two traits, so a `--features +//! cuda` build tunes an NVIDIA card with the same corpus, the same CPU oracle, +//! the same latency ceiling, the same soak and the same equivalence proof an +//! OpenCL build uses on an AMD one. That is not a convenience. The whole reason +//! a tuner exists rather than a table is that two cards want opposite things: +//! on an RX 9070 XT the kernel is latency bound and unit_size 192 beats 64 by +//! about 9%, while on a Tesla T4 at repeat 16 the ordering REVERSES (64 -> 7.54 +//! MH/s, 96 -> 7.19, 128 -> 7.06) because that card sits at 66 to 67 W against +//! a 70 W cap and a bigger batch cannot buy work the power limit will not +//! allow. Two cards, opposite optima, one standard for judging them. +//! +//! What the CUDA path cannot borrow from OpenCL is `x16rs_gate ab`, which +//! alternates two KERNEL TREES inside one process and resolves ~0.3%. nvcc +//! compiles CUDA kernels into the binary, so two CUDA kernels are two binaries. +//! Launch SHAPES are a different question and are alternated in-process here on +//! both backends; how finely that resolved is measured from the finalists' own +//! repeats and printed, rather than assumed. See `resolution_note`. + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +use crate::efficiency::{BenchmarkPick, EfficiencyMode}; + +// --------------------------------------------------------------------------- +// The fixed corpus +// --------------------------------------------------------------------------- + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub use crate::x16rs_gate::Shape; + +/// Greatest common divisor, iterative so a pathological pair cannot blow a +/// stack in a mining process. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = a % b; + a = b; + b = t; + } + a +} + +/// Least common multiple, or `None` on overflow. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn lcm(a: u64, b: u64) -> Option { + if a == 0 || b == 0 { + return None; + } + (a / gcd(a, b)).checked_mul(b) +} + +/// The corpus every candidate in one tuning session processes. +/// +/// It is cut into `segments` equal blocks of `segment_nonces` consecutive +/// nonces, and block number s is hashed against corpus header `s % headers`. +/// A candidate covers each segment with a whole number of its own batches, +/// which is what makes the (header, nonce) multiset identical for every +/// candidate rather than merely similar. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Corpus { + pub nonce_start: u32, + pub headers: u32, + pub segment_nonces: u64, + pub segments: u32, +} + +/// One launch: which corpus header, and where in the nonce space. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CorpusBatch { + pub header_index: u32, + pub nonce_start: u32, + pub nonces: u64, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl Corpus { + pub fn total_nonces(&self) -> u64 { + self.segment_nonces.saturating_mul(self.segments as u64) + } + + /// True when `shape` can tile every segment exactly. + pub fn fits(&self, shape: Shape) -> bool { + let batch = shape.nonces(); + batch > 0 && self.segment_nonces % batch == 0 + } + + /// The launches `shape` must perform to cover the corpus, in order. + /// + /// Refuses rather than truncates when the shape does not tile a segment: a + /// candidate that covered 31/32 of every segment would be measured on less + /// work than its rivals and would win by not doing it. + pub fn batches(&self, shape: Shape) -> Result, String> { + let batch = shape.nonces(); + if !self.fits(shape) { + return Err(format!( + "launch shape {}x{}x{} hashes {batch} nonces, which does not divide the \ + {}-nonce corpus segment; it cannot hash the same work as the other candidates", + shape.work_groups, shape.local_size, shape.unit_size, self.segment_nonces + )); + } + let total = self.total_nonces(); + if total == 0 || total > u32::MAX as u64 { + return Err(format!( + "corpus of {total} nonces does not fit the 32-bit nonce space" + )); + } + if self.nonce_start as u64 + total > u32::MAX as u64 { + return Err(format!( + "corpus [{}, {}) runs off the end of the 32-bit nonce space", + self.nonce_start, + self.nonce_start as u64 + total + )); + } + let per_segment = self.segment_nonces / batch; + let mut out = Vec::with_capacity((per_segment * self.segments as u64) as usize); + for segment in 0..self.segments as u64 { + let header_index = (segment % self.headers.max(1) as u64) as u32; + let base = self.nonce_start as u64 + segment * self.segment_nonces; + for index in 0..per_segment { + out.push(CorpusBatch { + header_index, + nonce_start: (base + index * batch) as u32, + nonces: batch, + }); + } + } + Ok(out) + } + + /// What `shape` will hash, reduced to the smallest form that still names + /// every (header_index, nonce) pair: the maximal runs of consecutive nonces + /// that carry one header, in the order they are hashed. + /// + /// Two shapes with equal signatures hash the identical sequence of + /// (header_index, nonce) pairs. That is not an assertion about the code, it + /// is a consequence of what this function checks on the way: every batch + /// starts exactly where the previous one ended, so a coverage has no gap, no + /// overlap and no reordering, and a gapless ordered cover is determined by + /// its runs. `identical_coverage_is_exactly_an_identical_signature` proves + /// the two forms agree by expanding both into individual pairs. + /// + /// This exists because the direct comparison does not fit in memory: one + /// corpus is tens of millions of nonces and there are up to forty-five + /// candidates, so materialising the pairs is gigabytes per shape. + pub fn coverage_signature(&self, shape: Shape) -> Result, String> { + let batches = self.batches(shape)?; + let mut runs: Vec<(u32, u32, u64)> = Vec::new(); + let mut next_nonce: Option = None; + for batch in &batches { + let start = batch.nonce_start as u64; + if let Some(expected) = next_nonce { + if start != expected { + return Err(format!( + "launch shape {}x{}x{} would hash nonce {start} straight after nonce {}; \ + its coverage is not a gapless ordered cover of the corpus", + shape.work_groups, + shape.local_size, + shape.unit_size, + expected - 1 + )); + } + } + next_nonce = Some(start + batch.nonces); + match runs.last_mut() { + Some(run) if run.0 == batch.header_index && run.1 as u64 + run.2 == start => { + run.2 += batch.nonces; + } + _ => runs.push((batch.header_index, batch.nonce_start, batch.nonces)), + } + } + let covered: u64 = runs.iter().map(|run| run.2).sum(); + if covered != self.total_nonces() { + return Err(format!( + "launch shape {}x{}x{} covers {covered} of the corpus's {} nonces", + shape.work_groups, + shape.local_size, + shape.unit_size, + self.total_nonces() + )); + } + Ok(runs) + } +} + +/// The smallest segment size every one of `batch_sizes` divides exactly, scaled +/// up to at least `min_nonces`. +/// +/// `None` when the answer would exceed `cap`. The caller's job then is to drop +/// the candidate that is forcing the quantum up and try again, which is honest: +/// a shape that cannot share the corpus cannot be compared on it. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn shared_segment_nonces(batch_sizes: &[u64], min_nonces: u64, cap: u64) -> Option { + let mut quantum = 1u64; + for size in batch_sizes { + quantum = lcm(quantum, *size)?; + if quantum > cap { + return None; + } + } + let multiple = min_nonces.div_ceil(quantum.max(1)).max(1); + let scaled = quantum.checked_mul(multiple)?; + (scaled <= cap).then_some(scaled) +} + +/// The corpus segment a set of shapes needs, given that a segment must be at +/// least `min_nonces` and no more than `cap`. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn segment_for(shapes: &[Shape], min_nonces: u64, cap: u64) -> Option { + let sizes: Vec = shapes.iter().map(|shape| shape.nonces()).collect(); + shared_segment_nonces(&sizes, min_nonces, cap) +} + +/// Build a corpus that every shape in `shapes` can tile, dropping the shapes +/// that force the quantum past `cap`. +/// +/// Returns the corpus, the shapes that survived and the shapes that were +/// dropped, so the caller can say which candidates it is not going to measure +/// and why, instead of quietly measuring them on different work. +/// +/// `cap` is a wall-clock decision, not a memory one: `plan_session` sets it to +/// the nonces this card hashes in the longest pass the sweep and the soak can +/// both afford. A shape dropped here is a shape the operator's budget cannot +/// pay for, and the tuner says so along with the budget that would. +/// +/// The shape dropped at each step is the one whose removal shrinks the quantum +/// the most, which is not the same as the one with the largest batch: a batch of +/// 216 832 nonces (7 x 256 x 121) forces the quantum up by a factor of 847 while +/// a batch of 262 144 (32 x 256 x 32) divides it away entirely. Dropping by size +/// would throw away the useful shape and keep the awkward one. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn plan_corpus( + shapes: &[Shape], + nonce_start: u32, + headers: u32, + segments: u32, + min_segment_nonces: u64, + cap_segment_nonces: u64, +) -> Result<(Corpus, Vec, Vec), String> { + if shapes.is_empty() { + return Err("no candidate launch shapes".to_string()); + } + let mut kept: Vec = shapes.to_vec(); + let mut dropped = Vec::new(); + loop { + if let Some(segment_nonces) = segment_for(&kept, min_segment_nonces, cap_segment_nonces) { + let corpus = Corpus { + nonce_start, + headers: headers.max(1), + segment_nonces, + segments: segments.max(1), + }; + kept.sort_by_key(|shape| (shape.work_groups, shape.unit_size)); + return Ok((corpus, kept, dropped)); + } + if kept.len() <= 1 { + return Err(format!( + "no corpus segment under {cap_segment_nonces} nonces can be tiled by the \ + candidate launch shapes; the last one hashes {} nonces per batch", + kept.first().map(|s| s.nonces()).unwrap_or(0) + )); + } + // Which shape is inflating the quantum? A least common multiple is + // (the largest power of two) x (the l.c.m. of the odd parts), and it is + // the odd part that hurts: one batch of 216 832 = 2^8 x 847 multiplies + // everyone else's segment by 847, while a batch of 262 144 = 2^18, + // though larger, multiplies it by nothing. So the shape removed is the + // one with the largest odd factor, and only where those tie does size + // decide. Picking by size alone would delete the useful shape and keep + // the awkward one. + let odd_part = |mut value: u64| { + while value % 2 == 0 && value > 0 { + value /= 2; + } + value + }; + let worst = (0..kept.len()) + .max_by_key(|index| { + let batch = kept[*index].nonces(); + (odd_part(batch), batch) + }) + .unwrap_or(0); + dropped.push(kept.remove(worst)); + } +} + +// --------------------------------------------------------------------------- +// Where the corpus is placed, and how big it is allowed to be +// --------------------------------------------------------------------------- + +/// Where a tuning corpus starts in the 32-bit nonce space. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const NONCE_BASE: u32 = 0x2000_0000; + +/// Where the probe hashes, kept clear of the corpus so a probe batch can never +/// be mistaken for corpus work. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const PROBE_NONCE_BASE: u32 = 0x1000_0000; + +/// Smallest corpus segment worth timing. Below about a quarter of a second the +/// per-launch overheads and the sampler's 100 ms period start to show. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const MIN_SEGMENT_NONCES: u64 = 1 << 21; + +/// The share of the operator's budget the sweeps may spend. The soak runs on +/// top of the budget, so the sweeps are not allowed all of it. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const SWEEP_BUDGET_SHARE: f64 = 0.8; + +/// Passes the refinement stage is budgeted for: two finalists, up to four +/// neighbours each on a two-axis grid. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const REFINE_PASS_ALLOWANCE: u32 = 8; + +/// Passes the final round is budgeted for: three finalists, three passes each. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const FINAL_PASS_ALLOWANCE: u32 = 9; + +/// How much of the longest settleable pass the plan is allowed to use. +/// +/// The soak's arithmetic gives a hard ceiling; a card that turns out slower than +/// its probe said, or a first pass that carries a kernel upload, must not push +/// the plan over it. 0.6 leaves the soak room for a pass two thirds longer than +/// planned and still settle. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const SOAK_PASS_MARGIN: f64 = 0.6; + +/// How much faster than the probe shape a large shape is allowed to be before +/// the latency prune stops believing the probe. +/// +/// The probe runs the smallest candidate, and small shapes under-feed this +/// kernel: on the RX 9070 XT the shipped 48x256x48 measured 19.13 MH/s against +/// 28.80 for 64x256x192, a factor of 1.51. 1.6 keeps the prune on the generous +/// side of the only spread anyone has measured, so a shape is dropped for +/// latency only when it cannot fit the ceiling even at the best rate this +/// kernel has ever shown. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const LATENCY_HEADROOM: f64 = 1.6; + +/// Passes that must be flat together before a soak is accepted. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn soak_window_passes() -> usize { + SettleLimits::default().window.max(2) +} + +/// The soak's wall-clock cap. Long enough for an air-cooled card to reach a +/// steady temperature, and larger when the operator's budget is larger. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn soak_cap_seconds(budget_seconds: u64) -> f64 { + (budget_seconds as f64 * 0.5).max(90.0).min(900.0) +} + +/// The soak's minimum duration, so a shape cannot be declared settled on five +/// passes taken over fifteen seconds. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn soak_floor_seconds(budget_seconds: u64) -> f64 { + 45.0f64.min(soak_cap_seconds(budget_seconds)) +} + +/// The longest one corpus pass may take if the soak is to reach its settling +/// window at all. +/// +/// `soak_until_settled` begins a pass only while `elapsed < cap`, so to begin +/// the `w`-th pass it must have spent less than `cap` on the first `w - 1`. +/// A pass of `p` seconds therefore reaches the window only when +/// `(w - 1) * p < cap`. This is the arithmetic the previous version of this +/// module did not check anywhere: a card whose pass exceeded it soaked for the +/// whole cap, never reached five passes, and was told to re-run with a larger +/// `benchmark_seconds`, which does not shorten a pass. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn max_soak_pass_seconds(budget_seconds: u64) -> f64 { + soak_cap_seconds(budget_seconds) / (soak_window_passes() as f64 - 1.0) +} + +/// Everything one tuning session will measure, and what it will cost, decided +/// before a single candidate is measured. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Debug)] +pub struct SessionPlan { + pub corpus: Corpus, + /// The coarse sweep, after both prunes, in the order it will be measured. + pub candidates: Vec, + /// Every shape the corpus can tile, which is the pool refinement draws from. + pub usable: Vec, + /// Dropped because their batch cannot meet the p95 latency ceiling at the + /// probed rate, so they could never have been chosen. + pub over_ceiling: Vec, + /// Dropped because they would push the corpus segment past what the budget + /// can pay for. + pub off_corpus: Vec, + pub probe_hps: f64, + /// One pass of the corpus, at the probed rate. + pub pass_seconds: f64, + /// The cap that decided the corpus segment. + pub pass_ceiling_seconds: f64, + pub sweep_passes: u32, + pub sweep_seconds: f64, + /// The `benchmark_seconds` at which nothing would have been dropped for + /// cost, or `None` when no budget can buy them back. + pub budget_for_every_shape: Option, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl SessionPlan { + /// A tune is a comparison. One shape measured is a report. + pub fn is_a_comparison(&self) -> bool { + self.candidates.len() >= 2 + } + + /// True when the soak can reach its settling window on this corpus at the + /// probed rate. `plan_session` refuses to return a plan where it is false. + pub fn soak_can_settle(&self, budget_seconds: u64) -> bool { + self.pass_seconds > 0.0 && self.pass_seconds < max_soak_pass_seconds(budget_seconds) + } +} + +/// Decide the whole session: which shapes are worth measuring, on what corpus, +/// and what that will cost in wall-clock seconds. +/// +/// This is the function the fix lives in, and it is deliberately free of the +/// device so it can be driven from a test at any hashrate for any card in +/// `PANEL_GPU_PRESETS`. `tune` calls it and does what it says. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[allow(clippy::too_many_arguments)] +pub fn plan_session( + min_work_groups: u32, + max_work_groups: u32, + max_unit_size: u32, + local_size: u32, + probe_hps: f64, + budget_seconds: u64, + headers: u32, + nonce_start: u32, +) -> Result { + if !probe_hps.is_finite() || probe_hps <= 0.0 { + return Err(format!( + "the probe measured {probe_hps} hashes per second, which is not a rate a corpus can \ + be sized from" + )); + } + let universe = candidate_universe(min_work_groups, max_work_groups, max_unit_size, local_size); + let coarse = coarse_candidates(min_work_groups, max_work_groups, max_unit_size, local_size); + if universe.is_empty() || coarse.is_empty() { + return Err("no launch shape fits this device's limits".to_string()); + } + + // 1. The latency prune. `score` refuses a candidate whose p95 batch is over + // the ceiling, so a shape whose single batch cannot fit it is work that + // buys nothing: it cannot win, and it drags the shared corpus, the CPU + // oracle in `prove_shape` and the wall clock up with it. The smallest + // shape is never pruned, so this can never empty the set on its own. + let smallest = universe.iter().map(|s| s.nonces()).min().unwrap_or(1); + let batch_ceiling = ((P95_BATCH_CEILING_MS / 1000.0) * LATENCY_HEADROOM * probe_hps) as u64; + let batch_ceiling = batch_ceiling.max(smallest); + let (affordable, over_ceiling): (Vec, Vec) = universe + .iter() + .copied() + .partition(|shape| shape.nonces() <= batch_ceiling); + + // 2. How long a pass may be. Two independent ceilings, and the plan takes + // the lower: the sweep has to fit the operator's budget, and the soak has + // to be able to settle on the corpus the sweep chose. + let expected_passes = coarse.len() as u32 + REFINE_PASS_ALLOWANCE + FINAL_PASS_ALLOWANCE; + let sweep_pass_seconds = budget_seconds as f64 * SWEEP_BUDGET_SHARE / expected_passes as f64; + let soak_pass_seconds = max_soak_pass_seconds(budget_seconds) * SOAK_PASS_MARGIN; + let pass_ceiling_seconds = sweep_pass_seconds.min(soak_pass_seconds); + + // The corpus also has to fit the 32-bit nonce space it is placed in, with + // room for several segments above the base. + let space_cap = (u32::MAX as u64 - nonce_start as u64) / 4; + let quantum_cap = ((pass_ceiling_seconds * probe_hps) as u64) + .max(MIN_SEGMENT_NONCES) + .min(space_cap); + + let (mut corpus, usable, off_corpus) = plan_corpus( + &affordable, + nonce_start, + headers, + 1, + MIN_SEGMENT_NONCES, + quantum_cap, + ) + .map_err(|error| { + format!( + "{error}. At the probed {:.2} MH/s a pass may last {:.1}s, which is {} nonces; the \ + smallest launch this device offers is {} nonces. Lower [gpu] work_groups, or raise \ + [efficiency] benchmark_seconds", + probe_hps / 1e6, + pass_ceiling_seconds, + quantum_cap, + smallest + ) + })?; + + // 3. Segments. One is the floor, not four: the header count follows the + // segments rather than forcing them. + let segment = corpus.segment_nonces.max(1); + let want_nonces = (sweep_pass_seconds * probe_hps).max(segment as f64); + let space_segments = ((u32::MAX as u64 - nonce_start as u64) / segment).max(1); + let soak_segments = (((soak_pass_seconds * probe_hps) as u64) / segment).max(1); + corpus.segments = (want_nonces / segment as f64).round().max(1.0).min(64.0) as u64 as u32; + corpus.segments = corpus + .segments + .min(space_segments.min(soak_segments).min(u32::MAX as u64) as u32) + .max(1); + corpus.headers = headers.max(1).min(corpus.segments); + + let candidates: Vec = coarse + .iter() + .copied() + .filter(|shape| usable.contains(shape)) + .collect(); + + let pass_seconds = corpus.total_nonces() as f64 / probe_hps; + let sweep_passes = candidates.len() as u32 + REFINE_PASS_ALLOWANCE + FINAL_PASS_ALLOWANCE; + + let plan = SessionPlan { + corpus, + candidates, + usable, + over_ceiling, + off_corpus, + probe_hps, + pass_seconds, + pass_ceiling_seconds, + sweep_passes, + sweep_seconds: pass_seconds * sweep_passes as f64, + budget_for_every_shape: budget_for_every_shape( + &affordable, + probe_hps, + expected_passes, + space_cap, + ), + }; + + if !plan.is_a_comparison() { + return Err(format!( + "only {} launch shape survived planning ({} could not meet the {:.0} ms batch ceiling \ + at {:.2} MH/s, {} would not fit a {:.1}s pass). A tune of one shape is a report, not \ + a comparison. Lower [gpu] work_groups so the window starts smaller, or raise \ + [efficiency] benchmark_seconds", + plan.candidates.len(), + plan.over_ceiling.len(), + P95_BATCH_CEILING_MS, + probe_hps / 1e6, + plan.off_corpus.len(), + pass_ceiling_seconds, + )); + } + // Belt and braces on the arithmetic this whole section exists to enforce. + // If it ever fails, the tuner must say so here rather than after forty + // minutes of sweeping followed by "the card never settled". + if !plan.soak_can_settle(budget_seconds) { + return Err(format!( + "one pass of the planned corpus takes {:.1}s at {:.2} MH/s, and a soak can only settle \ + on passes under {:.1}s, so this tune could sweep for {:.0}s and still never settle. \ + Raise [efficiency] benchmark_seconds, or lower [gpu] work_groups", + plan.pass_seconds, + probe_hps / 1e6, + max_soak_pass_seconds(budget_seconds), + plan.sweep_seconds, + )); + } + Ok(plan) +} + +/// The `benchmark_seconds` at which no shape would be dropped for cost. +/// +/// Both ceilings have to clear the full quantum: the sweep's share of the budget +/// spread over the passes it will make, and the soak's settling window. The soak +/// cap saturates at 900 s, so beyond a certain quantum no budget buys the shapes +/// back and the answer is `None` rather than a number that would not work. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn budget_for_every_shape( + shapes: &[Shape], + probe_hps: f64, + expected_passes: u32, + space_cap: u64, +) -> Option { + let quantum = segment_for(shapes, MIN_SEGMENT_NONCES, space_cap)?; + let seconds = quantum as f64 / probe_hps; + let for_sweep = seconds * expected_passes as f64 / SWEEP_BUDGET_SHARE; + // soak_cap(b) / (w - 1) * margin >= seconds, and soak_cap(b) = b / 2 once + // the budget is over the 180 s the 90 s floor covers. + let cap_needed = seconds * (soak_window_passes() as f64 - 1.0) / SOAK_PASS_MARGIN; + if cap_needed > 900.0 { + return None; + } + let for_soak = cap_needed * 2.0; + Some(for_sweep.max(for_soak).ceil() as u64) +} + +// --------------------------------------------------------------------------- +// What a candidate is judged on +// --------------------------------------------------------------------------- + +/// Hacash targets one block every 300 seconds. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const TARGET_BLOCK_SECONDS: f64 = 300.0; + +/// The p95 batch-latency ceiling, and where the number comes from. +/// +/// A batch cannot be interrupted: once it is enqueued, a template change cannot +/// take effect until it returns. If the job changes at a uniformly random moment +/// inside a batch of L seconds, L/2 seconds of hashing is thrown away, and at a +/// 300-second block target that is L/2/300 of the miner's output. 1.5 s holds +/// that expected loss under 0.25% while leaving room for the largest launch this +/// card can make. It is a ceiling on the p95 rather than the mean because what +/// hurts is the tail: a shape whose worst batches take four seconds delays every +/// template change by four seconds however good its average is. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub const P95_BATCH_CEILING_MS: f64 = 1_500.0; + +/// Fraction of hashing thrown away by template changes, for a given mean batch. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn stale_fraction(mean_batch_seconds: f64) -> f64 { + if !mean_batch_seconds.is_finite() || mean_batch_seconds <= 0.0 { + return 0.0; + } + (mean_batch_seconds / 2.0 / TARGET_BLOCK_SECONDS).clamp(0.0, 1.0) +} + +/// Raw hashrate discounted by the work template changes will throw away. This, +/// not the raw figure, is what "sustained valid H/s" means. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn sustained_valid_hps(hashrate: f64, mean_batch_seconds: f64) -> f64 { + if !hashrate.is_finite() || hashrate <= 0.0 { + return 0.0; + } + hashrate * (1.0 - stale_fraction(mean_batch_seconds)) +} + +/// Where the watts in a score came from. Printed next to every number, because +/// a measured 291 W and a configured 350 W lead to different winners and the +/// operator has to be able to tell which one picked theirs. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WattsSource { + /// The card's own board-power sensor. + Measured, + /// The configured `gpu_watts` scaled by the profile and the launch size. + Estimated, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl WattsSource { + pub fn label(self) -> &'static str { + match self { + WattsSource::Measured => "measured", + WattsSource::Estimated => "estimated", + } + } +} + +/// The prices a Profit score needs, and the one quantity that is not a price: +/// how much HAC a hash per second earns in a day at the current difficulty. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, Default)] +pub struct Economics { + pub power_cost_kwh: f64, + pub hac_price: f64, + /// HAC per day per H/s at the network's current target. `None` when the + /// tuner could not learn the difficulty, which is not the same as zero. + pub hac_per_hps_day: Option, + /// Draw of the CPU threads that assist the GPU while mining. Always an + /// estimate; there is no per-core power sensor. + pub cpu_watts: f64, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl Economics { + pub fn eur_per_hps_day(&self) -> Option { + let hac = self.hac_per_hps_day?; + (hac > 0.0 && self.hac_price > 0.0).then(|| hac * self.hac_price) + } + + pub fn daily_power_cost_eur(&self, watts: f64) -> f64 { + watts * 24.0 / 1000.0 * self.power_cost_kwh + } +} + +/// What the tuner is actually maximising. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Objective { + /// Sustained valid hashes per second. + ValidHashrate, + /// Sustained valid hashes per joule. + HashesPerJoule, + /// Net EUR per day: block-reward revenue minus electricity. + NetIncome, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl Objective { + pub fn label(self) -> &'static str { + match self { + Objective::ValidHashrate => "sustained valid H/s", + Objective::HashesPerJoule => "sustained valid H/J", + Objective::NetIncome => "net EUR/day", + } + } +} + +/// Turn the operator's mode into something that can actually be computed from +/// what this rig measures, and say so when that is not what they asked for. +/// +/// Profit is the one that can fail: net income needs a price for a hash, which +/// needs the network's difficulty and a HAC price. Without both, net is not +/// merely inaccurate, it is undefined, and the honest fallback is the one that +/// maximises revenue: throughput. Silently ranking on kH/J instead would answer +/// a different question and never say it did. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn resolve_objective( + mode: EfficiencyMode, + econ: &Economics, +) -> (Objective, Option<&'static str>) { + match mode { + EfficiencyMode::Max => (Objective::ValidHashrate, None), + EfficiencyMode::Eco => (Objective::HashesPerJoule, None), + EfficiencyMode::Profit => { + if econ.power_cost_kwh <= 0.0 { + return ( + Objective::ValidHashrate, + Some( + "power_cost_kwh is not set, so electricity is free and net income is maximised by throughput", + ), + ); + } + match econ.eur_per_hps_day() { + Some(_) => (Objective::NetIncome, None), + None if econ.hac_price <= 0.0 => ( + Objective::ValidHashrate, + Some( + "hac_price is not set, so revenue has no value and net income cannot be ranked; ranking on throughput instead", + ), + ), + None => ( + Objective::ValidHashrate, + Some( + "the network difficulty could not be read, so the value of a hash is unknown; ranking on throughput instead", + ), + ), + } + } + } +} + +/// Everything the score of one candidate is computed from. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug)] +pub struct ScoreInput { + pub hashrate: f64, + pub mean_batch_seconds: f64, + pub p95_batch_ms: f64, + /// Board draw while this candidate was running. + pub gpu_watts: f64, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl ScoreInput { + pub fn valid_hps(&self) -> f64 { + sustained_valid_hps(self.hashrate, self.mean_batch_seconds) + } + + pub fn total_watts(&self, econ: &Economics) -> f64 { + self.gpu_watts + econ.cpu_watts + } + + /// Sustained valid hashes per joule. + pub fn hashes_per_joule(&self, econ: &Economics) -> f64 { + let watts = self.total_watts(econ); + if watts <= 0.0 { + return 0.0; + } + self.valid_hps() / watts + } + + /// Net EUR per day, or `None` when a hash has no known value. + pub fn net_eur_per_day(&self, econ: &Economics) -> Option { + let value = econ.eur_per_hps_day()?; + Some(self.valid_hps() * value - econ.daily_power_cost_eur(self.total_watts(econ))) + } + + /// A candidate whose worst batches stall the miner past the ceiling is + /// refused, whatever it scores. + pub fn within_latency_ceiling(&self, ceiling_ms: f64) -> bool { + self.p95_batch_ms.is_finite() && self.p95_batch_ms <= ceiling_ms + } +} + +/// Score one candidate. Higher is better in every objective. +/// +/// `None` means the candidate is not admissible at all: a measurement that is +/// not finite and positive, or a shape that blows the latency ceiling. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn score( + input: &ScoreInput, + objective: Objective, + econ: &Economics, + ceiling_ms: f64, +) -> Option { + if !input.hashrate.is_finite() || input.hashrate <= 0.0 { + return None; + } + if !input.within_latency_ceiling(ceiling_ms) { + return None; + } + let value = match objective { + Objective::ValidHashrate => input.valid_hps(), + Objective::HashesPerJoule => input.hashes_per_joule(econ), + Objective::NetIncome => input.net_eur_per_day(econ)?, + }; + value.is_finite().then_some(value) +} + +// --------------------------------------------------------------------------- +// Settling +// --------------------------------------------------------------------------- + +/// One pass of the corpus during the soak, with the telemetry taken while it ran. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, Default)] +pub struct SoakPass { + pub seconds: f64, + pub hashrate: f64, + /// The pass's p95 BATCH latency, which is the quantity the miner's stale + /// work is priced from and the one that moves when a card starts throttling + /// under a sustained load. Recorded per pass rather than only for the last + /// one, so a soak that settles on hashrate while its tail grows is visible. + pub p95_ms: f64, + pub temp_c: Option, + pub watts: Option, + pub clock_mhz: Option, +} + +/// How flat is flat enough. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug)] +pub struct SettleLimits { + /// Passes that must all be flat together. + pub window: usize, + pub temp_span_c: f64, + pub watts_span_pct: f64, + pub clock_span_pct: f64, + pub rate_span_pct: f64, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl Default for SettleLimits { + /// The numbers are the card's own noise, not aspirations. Within one process + /// the fixed-corpus baseline reproduces to about 0.3%, so a 1% hashrate span + /// is comfortably above the measurement floor and still tight enough that a + /// card still climbing its clock ramp cannot pass. 1 C is the resolution the + /// driver reports temperature in. + fn default() -> Self { + SettleLimits { + window: 5, + temp_span_c: 1.0, + watts_span_pct: 3.0, + clock_span_pct: 2.0, + rate_span_pct: 1.0, + } + } +} + +/// The spans measured over the settling window, and whether they are all inside +/// the limits. Sensors the card does not have are `None` and are not required. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, Default)] +pub struct SettleState { + pub passes: usize, + pub rate_span_pct: f64, + pub temp_span_c: Option, + pub watts_span_pct: Option, + pub clock_span_pct: Option, + pub settled: bool, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl SettleState { + /// The signals this card never reported, so "settled" can be read for what + /// it is worth. + /// + /// `settle_state` treats an absent sensor as satisfied, and it has to: a + /// card with no board-power sensor would otherwise soak until the cap on + /// every run. But "settled" over one signal and "settled" over four are + /// different claims, and a report that printed only the spans it had let the + /// weaker one wear the stronger one's word. Named here, printed there. + pub fn absent_signals(&self) -> Vec<&'static str> { + let mut absent = Vec::new(); + if self.temp_span_c.is_none() { + absent.push("temperature"); + } + if self.watts_span_pct.is_none() { + absent.push("board power"); + } + if self.clock_span_pct.is_none() { + absent.push("shader clock"); + } + absent + } +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn span(values: &[f64]) -> Option<(f64, f64)> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for value in values { + if !value.is_finite() { + return None; + } + lo = lo.min(*value); + hi = hi.max(*value); + } + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn span_pct(values: &[f64]) -> Option { + let (lo, hi) = span(values)?; + let mid = (lo + hi) / 2.0; + (mid > 0.0).then(|| (hi - lo) / mid * 100.0) +} + +/// Is the last `limits.window` passes' worth of telemetry flat? +/// +/// A sensor that is absent on every pass is not a reason to refuse to settle: +/// an NVIDIA card with no board-power sensor would otherwise soak forever. A +/// sensor that is present must be flat. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn settle_state(passes: &[SoakPass], limits: &SettleLimits) -> SettleState { + let window = limits.window.max(2); + if passes.len() < window { + return SettleState { + passes: passes.len(), + settled: false, + ..SettleState::default() + }; + } + let tail = &passes[passes.len() - window..]; + let rates: Vec = tail.iter().map(|pass| pass.hashrate).collect(); + let rate_span_pct = span_pct(&rates).unwrap_or(f64::INFINITY); + + let collect = |pick: fn(&SoakPass) -> Option| -> Option> { + let values: Vec = tail.iter().filter_map(|p| pick(p)).map(f64::from).collect(); + (values.len() == tail.len()).then_some(values) + }; + let temps = collect(|p| p.temp_c); + let watts = collect(|p| p.watts); + let clocks = collect(|p| p.clock_mhz); + + let temp_span_c = temps.as_deref().and_then(span).map(|(lo, hi)| hi - lo); + let watts_span_pct = watts.as_deref().and_then(span_pct); + let clock_span_pct = clocks.as_deref().and_then(span_pct); + + let ok = |measured: Option, limit: f64| measured.map(|v| v <= limit).unwrap_or(true); + let settled = rate_span_pct <= limits.rate_span_pct + && ok(temp_span_c, limits.temp_span_c) + && ok(watts_span_pct, limits.watts_span_pct) + && ok(clock_span_pct, limits.clock_span_pct); + + SettleState { + passes: passes.len(), + rate_span_pct, + temp_span_c, + watts_span_pct, + clock_span_pct, + settled, + } +} + +// --------------------------------------------------------------------------- +// The operator's temperature ceiling +// --------------------------------------------------------------------------- + +/// What `[efficiency] max_temp_c` is actually doing on this card. +/// +/// Three states rather than two, because "no ceiling was asked for" and "a +/// ceiling was asked for and cannot be enforced" are opposite situations that a +/// boolean would merge. The second is the one that used to be invisible: on a +/// card with no temperature source, `within_temperature_limit` saw an absent +/// peak, had nothing to compare, and returned `Ok`, so a ceiling the operator +/// set was silently satisfied by every shape including the one that cooks the +/// card. Absent is now its own state and it is said out loud. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TempCeiling { + /// `max_temp_c` is 0 or unset: nothing to enforce, and nothing to warn about. + NotRequested, + /// A ceiling is set and this machine reports this card's temperature. + Enforced { limit_c: f32 }, + /// A ceiling is set and nothing on this machine reports this card's + /// temperature, so it cannot be enforced at all. + Unenforceable { limit_c: f32 }, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl TempCeiling { + /// Resolve the state from the operator's setting and what the card offers. + pub fn resolve(max_temp_c: Option, sensor_reports_temperature: bool) -> TempCeiling { + match max_temp_c { + None => TempCeiling::NotRequested, + Some(limit_c) if sensor_reports_temperature => TempCeiling::Enforced { limit_c }, + Some(limit_c) => TempCeiling::Unenforceable { limit_c }, + } + } + + pub fn is_enforceable(self) -> bool { + !matches!(self, TempCeiling::Unenforceable { .. }) + } + + /// One line for the tune's log and one for its report, in the operator's + /// terms: the setting they typed and what it is worth here. + pub fn describe(self, sensor: &str) -> String { + match self { + TempCeiling::NotRequested => { + format!("no ceiling set ([efficiency] max_temp_c = 0), sensor: {sensor}") + } + TempCeiling::Enforced { limit_c } => format!( + "{limit_c:.0} C ceiling from [efficiency] max_temp_c, enforced against {sensor}" + ), + TempCeiling::Unenforceable { limit_c } => format!( + "{limit_c:.0} C ceiling from [efficiency] max_temp_c CANNOT BE ENFORCED: {sensor}" + ), + } + } +} + +/// What one measurement window is worth against the ceiling. +/// +/// `NotMeasured` is deliberately not `Under`: a window that reported no +/// temperature has not been checked, and calling that a pass is exactly the +/// silent no-op this replaces. The session-level guard is `TempCeiling`, which +/// refuses before the sweep when the card has no sensor at all; a `NotMeasured` +/// after that guard is a sampling gap in one short window, which the caller +/// reports rather than treats as proof of anything. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TempWindow { + /// No ceiling was asked for, so there is nothing to check. + NoCeiling, + /// A peak was measured and it is at or under the ceiling. + Under { peak_c: f32, limit_c: f32 }, + /// A ceiling is set and this window carries no temperature at all. + NotMeasured { limit_c: f32 }, +} + +/// Judge one window's peak against the operator's ceiling. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn temp_window_state( + peak_c: Option, + max_temp_c: Option, +) -> Result { + let Some(limit_c) = max_temp_c else { + return Ok(TempWindow::NoCeiling); + }; + let Some(peak_c) = peak_c else { + return Ok(TempWindow::NotMeasured { limit_c }); + }; + if peak_c > limit_c { + return Err(format!( + "reached {peak_c:.0} C, above the {limit_c:.0} C ceiling set in [efficiency] max_temp_c" + )); + } + Ok(TempWindow::Under { peak_c, limit_c }) +} + +// --------------------------------------------------------------------------- +// Percentiles +// --------------------------------------------------------------------------- + +/// Nearest-rank percentile of an ascending sample. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn percentile(sorted: &[f64], quantile: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let rank = (quantile.clamp(0.0, 1.0) * (sorted.len() as f64 - 1.0)).round() as usize; + sorted[rank.min(sorted.len() - 1)] +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn median(values: &[f64]) -> f64 { + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + if sorted.is_empty() { + return 0.0; + } + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } +} + +// --------------------------------------------------------------------------- +// Sizing one device allocation to serve several candidates +// --------------------------------------------------------------------------- + +/// The work-group count one device must be ALLOCATED with so that it can launch +/// `shape` and every other planned shape sharing `shape`'s unit_size. +/// +/// This exists for the backends where `unit_size` is baked into the allocation +/// and the kernel reads it from the miner (CUDA), so a candidate with a new +/// unit_size needs a new device while work groups can be clamped per launch. +/// Opening one device per unit_size instead of one per candidate is the +/// difference between three device opens in a sweep and twenty-four, and each +/// open costs an allocation, a kernel self-test and two warm-up batches. +/// +/// The rule is per unit_size and NOT "the largest shape in the plan", and the +/// difference is a real allocation failure rather than a nicety: a plan holding +/// both 3072x256x32 and 256x256x128 would, on the naive rule, allocate +/// 3072x256x128 - 100 M nonces, 3.6 GB - for a launch nobody was ever going to +/// make. Filtering by unit_size bounds the allocation by the largest batch the +/// plan actually contains. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn shared_allocation_work_groups(plan: &[Shape], shape: Shape) -> u32 { + plan.iter() + .filter(|planned| { + planned.local_size == shape.local_size && planned.unit_size == shape.unit_size + }) + .map(|planned| planned.work_groups) + .max() + .unwrap_or(shape.work_groups) + .max(shape.work_groups) +} + +// --------------------------------------------------------------------------- +// How well the final round can actually tell two shapes apart +// --------------------------------------------------------------------------- + +/// One finalist's repeated passes in the final round. +/// +/// The final round runs the finalists in alternating order and takes each one's +/// median, which cancels the drift that would otherwise hand the win to whichever +/// shape happened to run while the card was cool. What it does NOT do by itself +/// is say how far apart two medians have to be before the difference is real, +/// and until now nothing did: a tune could report a 0.2% win over a card whose +/// own repeats spanned 3% and the report would read exactly like a 20% win. +/// +/// The passes needed for that number are already being run. This keeps them. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +#[derive(Clone, Debug)] +pub struct FinalistRuns { + pub shape: Shape, + /// The objective's value on each pass, in the order the passes ran. + pub scores: Vec, +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +impl FinalistRuns { + pub fn median(&self) -> f64 { + median(&self.scores) + } + + /// Peak-to-peak span of this shape's OWN repeated passes, as a percentage of + /// its median. Identical work, identical shape, same card, minutes apart: a + /// non-zero span here is pure measurement noise. + pub fn span_pct(&self) -> f64 { + let centre = self.median(); + if self.scores.len() < 2 || centre <= 0.0 { + return 0.0; + } + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for value in &self.scores { + if !value.is_finite() { + return f64::INFINITY; + } + lo = lo.min(*value); + hi = hi.max(*value); + } + (hi - lo) / centre * 100.0 + } +} + +/// The resolution of the final round: the largest noise span any one finalist +/// showed on its own repeated passes. +/// +/// A margin between two DIFFERENT shapes that is smaller than the spread one +/// shape shows against itself has not been demonstrated. This is the same +/// argument `x16rs_gate ab` makes with its paired p10-p90, measured here from +/// the passes the tuner runs anyway rather than assumed from a constant. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn comparison_resolution_pct(finalists: &[FinalistRuns]) -> f64 { + finalists + .iter() + .filter(|f| f.scores.len() >= 2) + .map(|f| f.span_pct()) + .fold(0.0f64, f64::max) +} + +/// The winner's margin over the runner-up, as a percentage of the runner-up. +/// `None` when fewer than two finalists produced a score. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn winning_margin_pct(finalists: &[FinalistRuns]) -> Option { + let mut medians: Vec = finalists + .iter() + .filter(|f| !f.scores.is_empty()) + .map(|f| f.median()) + .collect(); + if medians.len() < 2 { + return None; + } + medians.sort_by(|a, b| b.total_cmp(a)); + (medians[1] > 0.0).then(|| (medians[0] - medians[1]) / medians[1] * 100.0) +} + +/// The sentence a tune has to be able to say about its own answer. +/// +/// Three claims, and the third is the one that only became sayable when the +/// tuner learned to measure CUDA: +/// +/// * what the choice between finalists was resolved to, MEASURED from the +/// finalists' own repeats; +/// * whether the winner's margin cleared it; +/// * that the absolute hashrate above is a number from THIS process, and two +/// processes on this rig have disagreed by +/// [`crate::x16rs_gate::BETWEEN_PROCESS_SPREAD_PCT`] on identical work. +/// +/// The third matters more on CUDA than on OpenCL and the note says why. Two +/// OpenCL KERNEL trees can be alternated inside one process (`x16rs_gate ab`) +/// because OpenCL compiles kernels at runtime from a directory; nvcc compiles +/// CUDA kernels into the binary, so two CUDA kernel builds are two binaries and +/// can only ever be compared across processes. Two launch SHAPES are a different +/// question and are compared in-process on both backends, which is what the +/// first two claims are about - the tuner does not inherit the between-process +/// figure just because it is running on CUDA. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn resolution_note(finalists: &[FinalistRuns], backend: &str) -> String { + let resolution = comparison_resolution_pct(finalists); + let mut text = format!( + " resolution : the finalists were re-run in alternating order inside ONE process; \ + the widest\n spread any single shape showed against itself was \ + {resolution:.2}%, which is what this\n comparison can resolve" + ); + match winning_margin_pct(finalists) { + Some(margin) if margin >= resolution && resolution > 0.0 => text.push_str(&format!( + ".\n The winner beat the runner-up by {margin:.2}%, which clears it" + )), + Some(margin) => text.push_str(&format!( + ".\n The winner beat the runner-up by only {margin:.2}%, which does \ + NOT clear it: these two\n shapes were not told apart, and either \ + would do" + )), + None => text.push_str( + ".\n Only one finalist produced a score, so nothing was compared \ + in this round", + ), + } + text.push_str(&format!( + "\n across runs : the hashrate above is this process's. Separate runs of this binary \ + on\n identical work have disagreed by ~{:.1}% on this rig, so a number \ + from\n another run is not comparable with it below that.", + crate::x16rs_gate::BETWEEN_PROCESS_SPREAD_PCT + )); + if backend == "cuda" { + text.push_str( + "\n For CUDA that bound also applies to KERNEL changes and cannot \ + be beaten:\n nvcc compiles the kernel into the binary, so two \ + kernel builds are two\n binaries and there is no in-process A/B \ + for them the way `x16rs_gate ab`\n alternates two OpenCL kernel \ + trees. Launch shapes, which is what this\n tune compares, are \ + alternated in-process and resolve as stated above.", + ); + } + text.push('\n'); + text +} + +// --------------------------------------------------------------------------- +// The candidate grid +// --------------------------------------------------------------------------- + +/// Every value on both tuning axes is 2^a or 3 * 2^a, and nothing else. +/// +/// That is not aesthetics, it is what makes a shared corpus possible at all. +/// The corpus segment must be a common multiple of every candidate's batch +/// size; with both axes of this form, every batch is 2^k * 3^j with j at most 2, +/// so the segment is at most nine times the largest batch. Admit one 112 +/// (2^4 * 7) or one 40 (2^3 * 5) and the segment jumps by a factor of 7 or 5, +/// which either makes every measurement seven times longer than it needs to be +/// or forces candidates out of the comparison. A grid step of roughly 1.5x is +/// also finer than this kernel's response to either axis, so nothing is lost by +/// it: on the 9070 XT the whole work-group axis is 32, 48, 64. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn dyadic_grid(min: u32, max: u32) -> Vec { + let min = min.max(1); + let max = max.max(min); + let mut out = Vec::new(); + let mut power = 1u32; + loop { + for value in [power, power.saturating_mul(3)] { + if value >= min && value <= max && !out.contains(&value) { + out.push(value); + } + } + let Some(next) = power.checked_mul(2) else { + break; + }; + if next > max { + break; + } + power = next; + } + if out.is_empty() { + // A window so narrow that it contains no grid point at all. The cap + // itself is then the only candidate: it is the shape the device can + // really run, and one point measured is better than none. + out.push(max); + } + out.sort_unstable(); + out +} + +/// Unit sizes, before clamping to the device. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn unit_size_grid(max_unit_size: u32) -> Vec { + dyadic_grid(32, max_unit_size.max(32)) +} + +/// Work-group counts, before clamping to the device. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn work_group_grid(min_wg: u32, max_wg: u32) -> Vec { + dyadic_grid(min_wg, max_wg) +} + +/// Every other point of a full axis, and always its top end. +/// +/// This is what makes a coarse pass coarse: the sweep visits half the grid, and +/// the refinement fills in the neighbours of whatever won, so a full product +/// sweep is never paid for. +/// +/// # Which half, and why it is not a free choice +/// +/// The dyadic grid alternates `2^k` and `3 * 2^k`, so taking every other point +/// takes one of those two families whole. Both cover the axis equally after +/// refinement, but they cost wildly different amounts to MEASURE: the shared +/// corpus segment is a common multiple of every candidate's batch, so a family +/// of pure powers of two gives a segment equal to the largest batch, while one +/// 3-multiple multiplies it by three. `plan_corpus` then has to drop shapes to +/// fit the budget, and it drops the 3-multiples first, for exactly that reason. +/// +/// Starting at index 0 unconditionally leaves which family is chosen to the +/// arbitrary question of whether the grid's bottom end happened to be cut by +/// `min`. It is 32 on an RX 9070 XT, so index 0 is a power of two and the sweep +/// is cheap. It is 48 on a Tesla T4 - whose 40 multiprocessors put the floor +/// between 32 and 48 - so index 0 is 48, every coarse candidate is a +/// 3-multiple, `plan_corpus` drops every one of them to fit the corpus, and the +/// tune ends with "only 0 launch shapes survived planning" on a card that has +/// nothing wrong with it. That is not a hypothetical: it is what a 90-second +/// budget does on the one NVIDIA card this kernel has been measured on. +/// +/// So the offset is chosen rather than assumed: whichever of the two families +/// contains more powers of two. Ties keep index 0, so nothing already measured +/// moves, and the axis covered is identical either way. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +fn coarse_axis(full: &[u32]) -> Vec { + let family = |offset: usize| -> Vec { + let mut out: Vec = full.iter().copied().skip(offset).step_by(2).collect(); + if let Some(last) = full.last() { + if !out.contains(last) { + out.push(*last); + } + } + out.sort_unstable(); + out + }; + let powers_of_two = |axis: &[u32]| axis.iter().filter(|v| v.is_power_of_two()).count(); + let evens = family(0); + let odds = family(1); + if powers_of_two(&odds) > powers_of_two(&evens) { + odds + } else { + evens + } +} + +/// The coarse candidate set for a device. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn coarse_candidates( + min_wg: u32, + max_wg: u32, + max_unit_size: u32, + local_size: u32, +) -> Vec { + let mut out = Vec::new(); + for work_groups in coarse_axis(&work_group_grid(min_wg, max_wg)) { + for unit_size in coarse_axis(&unit_size_grid(max_unit_size)) { + out.push(Shape { + work_groups, + local_size, + unit_size, + }); + } + } + out +} + +/// The full product grid: everything the coarse sweep or a refinement could +/// ever ask for. +/// +/// The corpus is planned over this once per session, minus whatever +/// `plan_session` prunes from it, so a finalist's neighbours are measured on the +/// same corpus as the coarse sweep rather than on one rebuilt around them. What +/// survives the prune is `SessionPlan::usable`, and refinement draws only from +/// there. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn candidate_universe( + min_wg: u32, + max_wg: u32, + max_unit_size: u32, + local_size: u32, +) -> Vec { + let mut out = Vec::new(); + for work_groups in work_group_grid(min_wg, max_wg) { + for unit_size in unit_size_grid(max_unit_size) { + out.push(Shape { + work_groups, + local_size, + unit_size, + }); + } + } + out +} + +/// The immediate neighbours of a shape on the full grid: the points the coarse +/// sweep skipped. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn refine_candidates(base: Shape, min_wg: u32, max_wg: u32, max_unit_size: u32) -> Vec { + let neighbours = |grid: &[u32], value: u32| -> Vec { + let Some(index) = grid.iter().position(|entry| *entry == value) else { + return Vec::new(); + }; + [index.checked_sub(1), index.checked_add(1)] + .into_iter() + .flatten() + .filter_map(|i| grid.get(i).copied()) + .collect() + }; + let mut out = vec![base]; + for unit_size in neighbours(&unit_size_grid(max_unit_size), base.unit_size) { + out.push(Shape { unit_size, ..base }); + } + for work_groups in neighbours(&work_group_grid(min_wg, max_wg), base.work_groups) { + out.push(Shape { + work_groups, + ..base + }); + } + out.sort_by_key(|shape| (shape.work_groups, shape.unit_size)); + out.dedup(); + out +} + +/// Name the winning shape with the profile tier it sits closest to, so the ini +/// the panel reads keeps meaning what it meant. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn profile_for_shape( + vendor: crate::gpu_arch::GpuVendor, + shape: Shape, + max_wg: u32, + max_unit_size: u32, +) -> String { + let ceiling = (max_wg as f64 * max_unit_size as f64).max(1.0); + let load = (shape.work_groups as f64 * shape.unit_size as f64 / ceiling).clamp(0.0, 1.0); + let tier = match load { + l if l < 0.20 => 0, + l if l < 0.40 => 1, + l if l < 0.65 => 2, + l if l < 0.90 => 3, + _ => 4, + }; + crate::efficiency::tier_profile_for_vendor(vendor, tier).to_string() +} + +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn pick_for_shape( + vendor: crate::gpu_arch::GpuVendor, + shape: Shape, + max_wg: u32, + max_unit_size: u32, +) -> BenchmarkPick { + BenchmarkPick { + profile: profile_for_shape(vendor, shape, max_wg, max_unit_size), + workgroups: shape.work_groups, + unitsize: shape.unit_size, + } +} + +// =========================================================================== +// Everything below needs a real device. +// =========================================================================== + +/// The device half of the tuner: one implementation, both backends. +/// +/// Nothing below this line is written twice. `x16rs_gate` already reduced a GPU +/// backend to two traits - a way to open a device at a launch shape +/// ([`crate::x16rs_gate::GateBackend`]) and three operations on the device +/// itself ([`crate::x16rs_gate::GateDevice`]) - and proved that the corpus, the +/// CPU oracle, the threshold arithmetic and the comparison do not need to know +/// which card they are running on. A tuner needs exactly those three operations +/// and nothing more: `best` to time a candidate, `count_and_shares` to prove it, +/// and `best` again for the reduction solo mining reads. +/// +/// So this module is generic over the same two traits, and the CUDA tuner is not +/// a second tuner. The corpus, the scoring, the latency ceiling, the soak, the +/// settling test, the temperature ceiling and the blame for a wrong hash are the +/// SAME CODE for an RX 9070 XT and a Tesla T4, which is the only way two cards' +/// answers can be compared at all. +/// +/// # What the two backends really do differ in, and where it is handled +/// +/// One thing, and it is not a preference: an OpenCL device takes its launch +/// shape per call, so one allocation sized at the top of the grid serves every +/// candidate under it, while a CUDA miner has `unit_size` baked into its device +/// buffers AND passed to the kernel from the miner struct, so a candidate with a +/// different `unit_size` needs a different miner. That is the whole difference, +/// it is answered by `GateBackend::device_is_bound_to_its_shape` and +/// `GateDevice::can_launch`, and [`Devices`] is the twenty lines that act on it. +#[cfg(any(feature = "ocl", feature = "cuda"))] +mod device { + use super::*; + use crate::x16rs_gate::{ + GateBackend, GateDevice, REPEAT16_HEIGHT, SHARE_LIST_CAPACITY, corpus_header, cpu_hash, + cpu_hash_window, threshold_miss_probability, threshold_ranks, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + /// Telemetry sampled while a candidate ran. + #[derive(Clone, Debug, Default)] + pub struct TelemetryWindow { + pub temp_c: Option, + /// The hottest single sample, not the mean. A shape that spends one + /// second above the operator's limit has been above it. + pub peak_temp_c: Option, + pub watts: Option, + pub clock_mhz: Option, + pub samples: usize, + } + + /// A background sampler bound to one card for the life of a tuning session. + /// + /// It samples on its own thread rather than between batches because a batch + /// is tens of milliseconds and reading the driver inside the timed region + /// would put the sensor's latency into the hashrate. + pub struct Sampler { + stop: Arc, + readings: Arc>>, + handle: Option>, + pub source: &'static str, + pub measures_power: bool, + /// Whether anything on this machine reports this card's temperature. + /// + /// Tracked separately from `measures_power` because the two are missing + /// on different cards: an NVIDIA part has a temperature and (through + /// nvidia-smi) a draw, while an Intel part has neither, and a tuner that + /// conflated them would refuse the wrong rigs. + pub measures_temperature: bool, + /// The board power CAP this card is running under, where the tool + /// reports one. Read once, because it is a setting and not a reading. + /// + /// It is here because of what a Tesla T4 measured at repeat 16: 66 to + /// 67 W against a 70 W limit, SM clock swinging 1140 to 1305 MHz, and + /// unit_size 64 beating 96 beating 128 - the exact REVERSE of the + /// ordering on an RX 9070 XT, where the kernel is latency bound and a + /// bigger batch helps. Both orderings are real and the tuner finds + /// either one from the hashrate alone. What the cap adds is the reason, + /// which is the difference between an operator believing the tune and an + /// operator overriding it. + pub power_limit_w: Option, + } + + #[derive(Clone, Copy, Debug)] + struct Reading { + at: Instant, + temp_c: Option, + watts: Option, + clock_mhz: Option, + } + + #[cfg(windows)] + fn adl_adapter() -> Option { + let reporting = crate::gpu_temp_adl::reporting_gpus(); + // Exactly one card, for the same reason the thermal monitor insists on + // it: ADL's adapter order is not the OpenCL device order, so with two + // cards answering there is no honest way to say whose watts these are. + match reporting.as_slice() { + [gpu] => Some(gpu.adapter_index), + _ => None, + } + } + + impl Sampler { + pub fn start( + thermal_file: &str, + gpu_index: u32, + vendor: crate::gpu_arch::GpuVendor, + ) -> Sampler { + let readings: Arc>> = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + + #[cfg(windows)] + if thermal_file.trim().is_empty() { + if let Some(adapter) = adl_adapter() { + let sink = Arc::clone(&readings); + let flag = Arc::clone(&stop); + let handle = std::thread::spawn(move || { + while !flag.load(Ordering::Relaxed) { + if let Some(sample) = crate::gpu_temp_adl::sample(adapter) { + if let Ok(mut out) = sink.lock() { + out.push(Reading { + at: Instant::now(), + temp_c: sample.temp_c, + watts: sample.board_power_w, + clock_mhz: sample.gfx_clock_mhz, + }); + } + } + std::thread::sleep(Duration::from_millis(100)); + } + }); + let measures_power = crate::gpu_temp_adl::board_power_w(adapter).is_some(); + let measures_temperature = + crate::gpu_temp_adl::temperature_c(adapter).is_some(); + return Sampler { + stop, + readings, + handle: Some(handle), + source: "AMD driver (ADL) board power, temperature and shader clock at 10 Hz", + measures_power, + measures_temperature, + // ADL reports a board draw but no board power LIMIT + // through the path this build uses, so there is nothing + // honest to put here on an AMD card. + power_limit_w: None, + }; + } + } + + // Everything else: the same backend the running miner publishes from, + // sampled once a second because each read costs a process spawn. + match crate::efficiency::detect_gpu_temp_sensor(thermal_file, gpu_index, vendor) { + Some((backend, _)) => { + // One combined read, so the three capabilities are decided + // by what the card actually answered rather than by three + // separate probes that could disagree. On NVIDIA this is a + // single `nvidia-smi` spawn for all three quantities. + let first = backend.read_sample(); + let measures_power = first.watts.is_some(); + // `detect_gpu_temp_sensor` only returns a backend that has + // already answered with a temperature, so this is true; it + // is computed rather than assumed so that a future backend + // that reports power alone cannot claim a thermometer. + let measures_temperature = first.temp_c.is_some(); + let power_limit_w = backend.read_power_limit_w(); + let sink = Arc::clone(&readings); + let flag = Arc::clone(&stop); + let label: &'static str = + Box::leak(format!("{} at 1 Hz", backend.label()).into_boxed_str()); + let handle = std::thread::spawn(move || { + while !flag.load(Ordering::Relaxed) { + let sample = backend.read_sample(); + if let Ok(mut out) = sink.lock() { + out.push(Reading { + at: Instant::now(), + temp_c: sample.temp_c, + watts: sample.watts, + clock_mhz: sample.clock_mhz, + }); + } + std::thread::sleep(Duration::from_millis(1000)); + } + }); + Sampler { + stop, + readings, + handle: Some(handle), + source: label, + measures_power, + measures_temperature, + power_limit_w, + } + } + None => Sampler { + stop, + readings, + handle: None, + source: "no GPU sensor on this machine", + measures_power: false, + measures_temperature: false, + power_limit_w: None, + }, + } + } + + /// Mean of everything sampled between `from` and now. + pub fn window(&self, from: Instant) -> TelemetryWindow { + let Ok(readings) = self.readings.lock() else { + return TelemetryWindow::default(); + }; + let taken: Vec<&Reading> = readings.iter().filter(|r| r.at >= from).collect(); + let mean = |pick: fn(&Reading) -> Option| -> Option { + let values: Vec = taken.iter().filter_map(|r| pick(r)).collect(); + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f32) + }; + TelemetryWindow { + temp_c: mean(|r| r.temp_c), + peak_temp_c: taken + .iter() + .filter_map(|r| r.temp_c) + .fold(None, |acc: Option, t| { + Some(acc.map_or(t, |a| a.max(t))) + }), + watts: mean(|r| r.watts), + clock_mhz: mean(|r| r.clock_mhz), + samples: taken.len(), + } + } + + pub fn peak_temp(&self) -> Option { + let readings = self.readings.lock().ok()?; + readings + .iter() + .filter_map(|r| r.temp_c) + .fold(None, |acc: Option, t| { + Some(acc.map_or(t, |a| a.max(t))) + }) + } + + /// What fraction of this card's power cap a measured draw is, where both + /// numbers exist. + pub fn power_cap_load(&self, watts: Option) -> Option { + let limit = f64::from(self.power_limit_w?); + let watts = f64::from(watts?); + (limit > 0.0).then_some(watts / limit) + } + } + + /// Draw at or above this share of the cap is a card being held BY the cap. + /// + /// Measured rather than chosen: a T4 under this kernel reported 66 to 67 W + /// against a 70 W limit, which is 0.94 to 0.96, while the same card idle + /// between batches sits near 0.15. Anything in that top band is the limiter + /// doing the deciding, and it is the one condition under which "give the + /// card more nonces per launch" is the wrong instinct. + pub const POWER_CAPPED_FRACTION: f64 = 0.93; + + impl Drop for Sampler { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + /// One candidate, measured. + #[derive(Clone, Debug)] + pub struct Measured { + pub shape: Shape, + pub seconds: f64, + pub nonces: u64, + pub hashrate: f64, + pub batch_ms_sorted: Vec, + pub telemetry: TelemetryWindow, + pub cpu_checks: u32, + /// The minimum hash over each corpus segment, in segment order. Every + /// candidate hashes the same nonces, so every candidate must produce the + /// same list, byte for byte. + pub segment_minimums: Vec<(u32, [u8; 32])>, + } + + impl Measured { + pub fn mean_batch_seconds(&self) -> f64 { + if self.batch_ms_sorted.is_empty() { + return 0.0; + } + self.batch_ms_sorted.iter().sum::() / self.batch_ms_sorted.len() as f64 / 1000.0 + } + pub fn p50_ms(&self) -> f64 { + percentile(&self.batch_ms_sorted, 0.50) + } + pub fn p95_ms(&self) -> f64 { + percentile(&self.batch_ms_sorted, 0.95) + } + pub fn score_input(&self, fallback_watts: f64) -> ScoreInput { + ScoreInput { + hashrate: self.hashrate, + mean_batch_seconds: self.mean_batch_seconds(), + p95_batch_ms: self.p95_ms(), + gpu_watts: self + .telemetry + .watts + .map(f64::from) + .unwrap_or(fallback_watts), + } + } + } + + /// Hash the whole corpus once with `shape`, timing every batch. + /// + /// Correctness is checked outside the timed region, deliberately: a CPU + /// re-hash inside it would be measuring the CPU. + #[allow(clippy::too_many_arguments)] + pub fn run_corpus( + device: &D, + shape: Shape, + corpus: &Corpus, + intros: &[Vec], + height: u64, + sampler: &Sampler, + ) -> Result { + let batches = corpus.batches(shape)?; + let per_segment = (corpus.segment_nonces / shape.nonces()) as usize; + let started_at = Instant::now(); + let mut batch_ms = Vec::with_capacity(batches.len()); + let mut results: Vec<(u32, [u8; 32], u32)> = Vec::with_capacity(batches.len()); + let wall_start = Instant::now(); + for batch in &batches { + let at = Instant::now(); + let (nonce, hash) = device.best( + shape, + height, + &intros[batch.header_index as usize], + batch.nonce_start, + )?; + batch_ms.push(at.elapsed().as_secs_f64() * 1000.0); + results.push((nonce, hash, batch.header_index)); + } + let seconds = wall_start.elapsed().as_secs_f64(); + if !seconds.is_finite() || seconds <= 0.0 { + return Err("non-positive run duration".to_string()); + } + + // Every batch's answer is re-hashed on the CPU with the consensus + // implementation. A timing number taken from a kernel that returned a + // wrong hash is worse than no number. + let mut cpu_checks = 0u32; + for (index, (nonce, hash, header_index)) in results.iter().enumerate() { + let batch = &batches[index]; + if nonce.wrapping_sub(batch.nonce_start) as u64 >= batch.nonces { + return Err(format!( + "batch at nonce {} returned nonce {nonce}, outside its own window", + batch.nonce_start + )); + } + if cpu_hash(height, &intros[*header_index as usize], *nonce) != *hash { + return Err(format!( + "GPU hash at nonce {nonce} does not match x16rs::block_hash" + )); + } + cpu_checks += 1; + } + + // The minimum over each segment. Identical work, so this list has to be + // identical for every candidate; see `agree_on_segments`. + let mut segment_minimums = Vec::with_capacity(corpus.segments as usize); + for segment in results.chunks(per_segment.max(1)) { + let mut best = segment[0]; + for entry in segment { + if crate::hash_util::hash_more_power(&entry.1, &best.1) { + best = *entry; + } + } + segment_minimums.push((best.0, best.1)); + } + + let nonces = corpus.total_nonces(); + batch_ms.sort_by(|a, b| a.total_cmp(b)); + Ok(Measured { + shape, + seconds, + nonces, + hashrate: nonces as f64 / seconds, + batch_ms_sorted: batch_ms, + telemetry: sampler.window(started_at), + cpu_checks, + segment_minimums, + }) + } + + /// Two candidates hashed the same nonces, so they must have found the same + /// best hash in every segment. + /// + /// This is free and it is strong: it is a 100%-coverage comparison of two + /// launch shapes over tens of millions of nonces, and the reference side of + /// it has already been proved against the CPU. + pub fn agree_on_segments(reference: &Measured, other: &Measured) -> Result<(), String> { + if reference.segment_minimums.len() != other.segment_minimums.len() { + return Err(format!( + "shape {}x{} produced {} segment results, the reference produced {}", + other.shape.work_groups, + other.shape.unit_size, + other.segment_minimums.len(), + reference.segment_minimums.len() + )); + } + for (index, (want, got)) in reference + .segment_minimums + .iter() + .zip(other.segment_minimums.iter()) + .enumerate() + { + if want != got { + return Err(format!( + "over corpus segment {index} the reference shape found nonce {} hash {}, \ + this shape found nonce {} hash {}; the two shapes do not agree on identical work", + want.0, + hex::encode(want.1), + got.0, + hex::encode(got.1) + )); + } + } + Ok(()) + } + + /// What a shape proof covered. + #[derive(Clone, Copy, Debug)] + pub struct ShapeProof { + pub window: u64, + pub thresholds: usize, + pub miss_probability: f64, + } + + /// Prove that ONE launch shape hashes its whole window exactly the way the + /// CPU consensus implementation does, before its speed is allowed to count. + /// + /// The kernel is the same for every shape, and `x16rs_gate equiv` is what + /// proves the kernel. What changes with the shape is how nonces are placed + /// on the card and how the per-work-group reduction is built, so what is + /// proved here is the shape: + /// + /// * with the weakest possible target every nonce qualifies, so the + /// kernel's own hit counter must equal the window exactly. A shape that + /// drops, repeats or overruns work fails here. + /// * at each of several rank thresholds taken from the sorted CPU oracle, + /// the kernel's hit count must equal the rank exactly. Each of those + /// launches reads every hash in the window. + /// * the shares that do come back are compared byte for byte. + /// * the best-hash reduction, which is the only path solo mining reads, + /// must return the true minimum of the window. + /// + /// This is the gate's production-shape pass, re-aimed at a candidate. It is + /// a separate implementation from `run_equivalence`'s because that one + /// accumulates a mismatch census for a human to read and aborts the run, + /// while this one is an admission ticket: a candidate that fails is dropped + /// and the tune continues. + pub fn prove_shape( + device: &D, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + thresholds: u32, + oracle_threads: usize, + ) -> Result { + let window = shape.nonces(); + if window == 0 || window > u32::MAX as u64 { + return Err(format!("launch shape hashes {window} nonces")); + } + let cpu = cpu_hash_window(height, intro, nonce_start, window as u32, oracle_threads); + let mut sorted = cpu.clone(); + sorted.sort_unstable(); + + let launch = + |target: &[u8; 32]| device.count_and_shares(shape, height, intro, nonce_start, target); + + // 1. The whole window, counted. + let (all_hits, _) = launch(&[0xffu8; 32])?; + if all_hits != window { + return Err(format!( + "with a target every nonce beats, the kernel counted {all_hits} hits over a \ + {window}-nonce window; this shape does not hash the work it is given" + )); + } + + // 2. Rank thresholds. + let ranks = threshold_ranks(window, SHARE_LIST_CAPACITY, thresholds); + for rank in ranks.iter().copied() { + let target = sorted[(rank - 1) as usize]; + let (hits, shares) = launch(&target)?; + if hits != rank { + return Err(format!( + "the CPU says exactly {rank} of the {window} hashes are <= {}, the kernel \ + counted {hits}; this shape's hashes differ from the CPU's", + hex::encode(target), + )); + } + for (nonce, hash) in &shares { + let offset = nonce.wrapping_sub(nonce_start) as u64; + if offset >= window { + return Err(format!("shape returned nonce {nonce}, outside the window")); + } + if cpu[offset as usize] != *hash { + return Err(format!( + "nonce {nonce}: gpu={} cpu={}", + hex::encode(hash), + hex::encode(cpu[offset as usize]) + )); + } + } + } + + // 3. The reduction solo mining actually reads. + let (best_nonce, best_hash) = device.best(shape, height, intro, nonce_start)?; + if best_hash != sorted[0] { + return Err(format!( + "the best-hash reduction returned {} for nonce {best_nonce}; the CPU minimum over \ + the window is {}", + hex::encode(best_hash), + hex::encode(sorted[0]) + )); + } + let offset = best_nonce.wrapping_sub(nonce_start) as u64; + if offset >= window || cpu[offset as usize] != best_hash { + return Err(format!( + "best nonce {best_nonce} does not carry its own hash" + )); + } + + Ok(ShapeProof { + window, + thresholds: ranks.len(), + miss_probability: threshold_miss_probability(window, &ranks), + }) + } + + /// Which card, on which backend. + /// + /// An enum rather than a set of optional fields so that the two backends + /// cannot both be half-specified, and so a build that lacks one of them can + /// still NAME it: a CUDA operator on an OpenCL-only binary gets a sentence + /// about CUDA, which is the whole point of this variant existing in every + /// build rather than behind a feature gate. + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum TuneTarget { + OpenCl { + opencl_dir: String, + platform: u32, + device_ids: String, + }, + Cuda { + device_index: i32, + }, + } + + impl TuneTarget { + pub fn label(&self) -> &'static str { + match self { + TuneTarget::OpenCl { .. } => "opencl", + TuneTarget::Cuda { .. } => "cuda", + } + } + } + + /// Everything the caller has to tell the tuner about the rig. + #[derive(Clone, Debug)] + pub struct TuneRequest { + pub target: TuneTarget, + pub local_size: u32, + pub min_work_groups: u32, + pub max_work_groups: u32, + pub max_unit_size: u32, + pub vendor: crate::gpu_arch::GpuVendor, + pub mode: EfficiencyMode, + /// Total wall-clock budget for the sweeps, in seconds. The soak runs on + /// top of it, until the card stops moving. + /// + /// It is also what sizes the corpus: `plan_session` divides it over the + /// passes a sweep will make and caps the corpus segment at that many + /// seconds of this card's hashing, so raising it buys a finer grid as + /// well as a longer soak. That is the whole reason the tuner can now + /// tell an operator which `benchmark_seconds` would measure the shapes + /// it had to drop. + pub budget_seconds: u64, + pub economics: Economics, + /// Board draw to fall back on when the card has no power sensor. + pub estimated_watts: f64, + pub thermal_file: String, + pub gpu_index: u32, + pub oracle_threads: usize, + pub headers: u32, + pub proof_thresholds: u32, + /// The operator's own temperature ceiling from `[efficiency] max_temp_c`, + /// where they set one. + /// + /// The running miner honours this by throttling work groups, so a tuner + /// that ignored it would drive the card past the limit its owner set, + /// measure a hashrate that limit forbids, and then write that shape into + /// the config for the miner to be throttled back out of. Measured on the + /// 9070 XT: the largest shape reaches 90 C, so this is not hypothetical. + pub max_temp_c: Option, + } + + /// What the tuner decided, and everything a reviewer needs to check it. + pub struct TuneOutcome { + pub pick: BenchmarkPick, + pub shape: Shape, + /// Which backend measured this, and which device it opened. Free text, + /// straight from the backend, so a report cannot be mistaken for the + /// other card's. + pub backend: &'static str, + pub device: String, + /// Devices opened over the session. One on OpenCL; one per distinct + /// (local_size, unit_size, larger work_groups) on CUDA, and printed so + /// the cost of that is visible rather than inferred. + pub device_opens: u32, + /// The finalists' medians and the span each one showed across its own + /// repeated passes. THIS is the resolution of the choice the tuner made: + /// see `resolution_note`. + pub finalists: Vec, + pub objective: Objective, + pub watts_source: WattsSource, + pub soak: Vec, + pub soak_seconds: f64, + pub settle: SettleState, + pub winner: Measured, + pub corpus: Corpus, + pub total_seconds: f64, + pub final_proof: ShapeProof, + pub peak_temp_c: Option, + /// The card's own board power cap, and what fraction of it the winning + /// shape drew. Both `None` where nothing reports one. + pub power_limit_w: Option, + pub power_cap_load: Option, + /// What the operator's `max_temp_c` was worth on this card. Carried into + /// the report so a tune that ran without a ceiling says so in the proof + /// block rather than only in a log line nobody keeps. + pub ceiling: TempCeiling, + /// The sensor line, so the report can name what did (or did not) measure. + pub sensors: &'static str, + /// What the plan said this would cost, kept so the report can put the + /// estimate next to the time it really took. + pub plan: SessionPlan, + } + + /// Rank thresholds in the proof the winning shape gets after the soak. + /// + /// Paid once, so it is sized for the answer rather than for the schedule: + /// 255 thresholds put a single wrong hash anywhere in the window past every + /// one of them with probability about 4e-3, against 3e-2 for the 31 an + /// admission proof uses. + const FINAL_PROOF_THRESHOLDS: u32 = 255; + + /// How long the probe hashes before its rate is believed, and the most + /// batches it will spend getting there. + /// + /// A third of a second is several times the 100 ms sampler period and long + /// enough that a launch overhead is not the measurement, while staying short + /// enough that a card which turns out to be very slow has not already cost + /// the operator a minute before the tuner can tell them so. + const PROBE_SECONDS: f64 = 0.35; + const PROBE_MAX_BATCHES: u32 = 64; + + /// Measure this card, once, on the smallest launch it offers, so the corpus + /// can be sized from what it really does instead of from what its work-group + /// ceiling implies. + /// + /// The first launch is thrown away: it carries the kernel upload and the + /// first buffer touch, and counting it would under-report the card by enough + /// to change the plan. The last answer is re-hashed on the CPU, because a + /// rate taken from a kernel that returned a wrong hash is not a rate. + fn probe_rate( + device: &D, + shape: Shape, + height: u64, + intro: &[u8], + ) -> Result { + let batch = shape.nonces(); + if batch == 0 || batch > u32::MAX as u64 { + return Err(format!("probe shape hashes {batch} nonces")); + } + let launch = |nonce_start: u32| device.best(shape, height, intro, nonce_start); + launch(PROBE_NONCE_BASE)?; + + let started = Instant::now(); + let mut hashed = 0u64; + let mut last = None; + for index in 0..PROBE_MAX_BATCHES { + let nonce_start = + PROBE_NONCE_BASE.wrapping_add(((index as u64 + 1) * batch % (1u64 << 32)) as u32); + last = Some((nonce_start, launch(nonce_start)?)); + hashed += batch; + if started.elapsed().as_secs_f64() >= PROBE_SECONDS { + break; + } + } + let seconds = started.elapsed().as_secs_f64(); + if !seconds.is_finite() || seconds <= 0.0 { + return Err("the probe took no measurable time".to_string()); + } + if let Some((nonce_start, (nonce, hash))) = last { + if nonce.wrapping_sub(nonce_start) as u64 >= batch { + return Err(format!( + "the probe returned nonce {nonce}, outside its own {batch}-nonce window" + )); + } + if cpu_hash(height, intro, nonce) != hash { + return Err( + "the probe's hash does not match x16rs::block_hash; this device is not \ + computing the consensus hash and nothing measured on it would mean anything" + .to_string(), + ); + } + } + Ok(hashed as f64 / seconds) + } + + /// Throwaway batches run after a device is opened, before anything is timed. + /// + /// The first launch on a fresh allocation carries the kernel upload (OpenCL) + /// or the module load and the first touch of several hundred megabytes of + /// device buffers (CUDA). On the OpenCL path the session opens one device and + /// the probe absorbs that cost once. On the CUDA path a candidate with a new + /// `unit_size` needs a new miner, so WITHOUT this every CUDA candidate's + /// first timed batch would carry it - and not equally: a shape with four + /// batches per pass would wear a quarter of it and a shape with forty a + /// fortieth, which is a bias that scales with the axis being tuned. Two + /// rather than one because the second is what shows the first was enough. + const WARMUP_BATCHES_AFTER_OPEN: u32 = 2; + + /// Where warm-up batches hash: far from the corpus and far from the probe, so + /// a warm-up launch can never be confused with measured work. + const WARMUP_NONCE_BASE: u32 = 0xF000_0000; + + /// The devices a session opens, and the one place the two backends differ. + /// + /// OpenCL takes the launch shape per call, so a single allocation sized at + /// the top corner of the grid serves every candidate under it: that is what + /// `device_is_bound_to_its_shape() == false` buys, and it is why an OpenCL + /// tune opens exactly one device and every candidate runs against the same + /// allocation, none of them flattered by a fresh context. + /// + /// CUDA cannot: `cuda_mine_batch` hands the kernel `miner.unit_size`, so a + /// miner built at 64 asked for 128 would run 64 and report 128. So on that + /// backend a device is opened per unit_size rather than per CANDIDATE. Work + /// groups are NOT baked in - `mine_block_batch_shares` clamps them per launch + /// against buffers sized at construction - so once the session knows which + /// shapes it will measure, one miner allocated at the largest work-group + /// count for a given unit_size serves every candidate that shares it. On a + /// typical grid that is a third of them, and it is the difference between + /// three device opens and twenty-four. + struct Devices<'b, B: GateBackend> { + backend: &'b B, + /// The allocation shared by every candidate, where the backend allows a + /// shared one. Ignored entirely when it does not. + allocation: Shape, + bound: bool, + /// Shapes this session may ask for. Used only to size a bound backend's + /// allocations, and empty until the plan exists: the probe runs before + /// there is a plan, and it gets an allocation of its own exact shape. + plan: Vec, + open: Option<(Shape, B::Device)>, + height: u64, + warm_intro: Vec, + opens: u32, + } + + impl<'b, B: GateBackend> Devices<'b, B> { + fn new(backend: &'b B, allocation: Shape, height: u64, warm_intro: Vec) -> Self { + Devices { + backend, + allocation, + bound: backend.device_is_bound_to_its_shape(), + plan: Vec::new(), + open: None, + height, + warm_intro, + opens: 0, + } + } + + /// Tell the pool which shapes the session will ask for, so a bound + /// backend can allocate once per unit_size instead of once per shape. + fn will_measure(&mut self, shapes: &[Shape]) { + self.plan = shapes.to_vec(); + } + + /// The shape a device must be ALLOCATED at to be able to launch `shape`. + /// + /// On an unbound backend that is the grid ceiling, always, so the whole + /// session runs against one allocation. On a bound one it is `shape`'s + /// own unit_size, which there is no choice about, with the work-group + /// count [`shared_allocation_work_groups`] derives from the plan. + fn allocation_for(&self, shape: Shape) -> Shape { + if !self.bound { + return self.allocation; + } + Shape { + work_groups: shared_allocation_work_groups(&self.plan, shape), + ..shape + } + } + + /// A device that can really launch `shape`, opening one if the currently + /// open device cannot. + fn at(&mut self, shape: Shape) -> Result<&B::Device, String> { + let serves = self + .open + .as_ref() + .is_some_and(|(_, device)| device.can_launch(shape)); + if !serves { + let allocate_at = self.allocation_for(shape); + self.backend.check_shape(allocate_at)?; + // Dropped BEFORE the new one is asked for. On CUDA the old + // miner holds hundreds of megabytes of device memory and the + // new one needs its own; holding both would make the largest + // candidate fail to allocate on exactly the cards where it + // matters most. + self.open = None; + let device = self.backend.open(allocate_at)?; + if !device.can_launch(shape) { + return Err(format!( + "{} opened a device for {allocate_at} that cannot launch {shape}", + self.backend.name() + )); + } + warm_up(&device, shape, self.height, &self.warm_intro)?; + self.opens += 1; + self.open = Some((allocate_at, device)); + } + Ok(&self + .open + .as_ref() + .expect("a device was just opened or already served this shape") + .1) + } + } + + /// Untimed launches that leave the card in the state a measurement expects. + fn warm_up( + device: &D, + shape: Shape, + height: u64, + intro: &[u8], + ) -> Result<(), String> { + let batch = shape.nonces(); + for index in 0..WARMUP_BATCHES_AFTER_OPEN { + let nonce_start = + WARMUP_NONCE_BASE.wrapping_add(((index as u64 * batch) % (1u64 << 32)) as u32); + device + .best(shape, height, intro, nonce_start) + .map_err(|error| format!("warm-up batch {} at {shape}: {error}", index + 1))?; + } + Ok(()) + } + + /// Run the tune. Returns the shape to write, or the reason there is none. + /// + /// The dispatch is the only place either backend is named. Everything after + /// it is [`tune_on`], compiled once and identical for both. + pub fn tune(request: &TuneRequest) -> Result { + match &request.target { + TuneTarget::OpenCl { + opencl_dir, + platform, + device_ids, + } => { + #[cfg(feature = "ocl")] + { + tune_on( + &crate::x16rs_gate::OclBackend { + opencl_dir: opencl_dir.clone(), + platform: *platform, + device: device_ids.clone(), + }, + request, + ) + } + #[cfg(not(feature = "ocl"))] + { + let _ = (opencl_dir, platform, device_ids); + Err( + "this binary was built without the OpenCL backend, so it cannot tune an \ + OpenCL device. Rebuild with --features ocl. Config unchanged" + .to_string(), + ) + } + } + TuneTarget::Cuda { device_index } => { + #[cfg(feature = "cuda")] + { + // Two different failures with two different remedies, and + // the reason this check is here rather than at the call + // site: `cuda` is a cargo feature that only adds the crate, + // while whether that crate holds KERNELS is decided by its + // build script finding nvcc. A binary can have the feature + // and no kernels, and every device call then returns + // NotCompiled, which used to reach the operator as a driver + // error. + if !crate::x16rs_gate::cuda_kernels_available() { + return Err("this binary has the cuda feature but NO CUDA kernels: \ + x16rs-cuda/build.rs did not find nvcc when it was built, so \ + cfg(cuda_available) was never set and every device call returns \ + `x16rs-cuda built without CUDA kernels`. Install the CUDA Toolkit, \ + set CUDA_PATH, and rebuild with --features cuda; the build prints \ + `Using CUDA Toolkit at ...` when it found one. Config unchanged" + .to_string()); + } + tune_on( + &crate::x16rs_gate::CudaBackend { + device_index: *device_index, + }, + request, + ) + } + #[cfg(not(feature = "cuda"))] + { + let _ = device_index; + Err( + "this binary was built without the CUDA backend, so it cannot tune an \ + NVIDIA card. Rebuild with --features cuda (the CUDA Toolkit must be \ + installed and CUDA_PATH set, or the build silently produces a binary \ + with no kernels). Config unchanged" + .to_string(), + ) + } + } + } + } + + /// The tune itself, on whichever backend was handed in. + pub fn tune_on( + backend: &B, + request: &TuneRequest, + ) -> Result { + let started = Instant::now(); + let height = REPEAT16_HEIGHT; + let repeat = x16rs::block_hash_repeat(height); + let local_size = request.local_size; + + // The whole universe is opened for, because the device allocation has to + // be the same for every candidate; which of those shapes is worth + // measuring is decided after the probe, not before it. + let universe = candidate_universe( + request.min_work_groups, + request.max_work_groups, + request.max_unit_size, + local_size, + ); + if universe.is_empty() { + return Err("no launch shape fits this device's limits".to_string()); + } + + // The exhaustive proof each candidate has to pass reads the kernel's own + // share list, so a backend whose list is a different size would make + // every "over its whole window" claim mean something else. This is the + // same refusal `run_equivalence_on` makes, for the same reason, before + // anything is measured. + let capacity = backend.share_capacity(); + if capacity != SHARE_LIST_CAPACITY { + return Err(format!( + "{} reports a share list capacity of {capacity}, this tuner proves shapes against \ + {SHARE_LIST_CAPACITY}", + backend.name() + )); + } + wlogln!( + "[autotune] backend {} on {}", + backend.name(), + backend.describe() + ); + + let sampler = Sampler::start(&request.thermal_file, request.gpu_index, request.vendor); + let watts_source = if sampler.measures_power { + WattsSource::Measured + } else { + WattsSource::Estimated + }; + let (objective, fallback_reason) = resolve_objective(request.mode, &request.economics); + wlogln!("[autotune] sensors: {}", sampler.source); + wlogln!( + "[autotune] mode={} optimises {} on {} watts", + request.mode.label(), + objective.label(), + watts_source.label() + ); + if let Some(reason) = fallback_reason { + wlogln!("[autotune] NOTE: {reason}"); + } + // Defect 1, said where it bites rather than left to be inferred from one + // word in the line above. With no per-candidate watt figure, every shape + // is divided by the same constant, so hashes-per-joule and net-EUR are + // affine in the hashrate and rank exactly as throughput does. An + // operator who chose Eco is being given Max, and has a right to know it + // before spending the tune. + if watts_source == WattsSource::Estimated && objective != Objective::ValidHashrate { + wlogln!( + "[autotune] NOTE: nothing on this machine reports this card's power draw, so every \ + candidate is scored on the same estimated {:.0} W. That makes {} rank the shapes \ + in exactly the order {} would: this tune cannot tell {} apart from max. Only a \ + card that reports its own watts can, and no [efficiency] gpu_watts value changes \ + it, because one constant divides every candidate alike", + request.estimated_watts, + objective.label(), + Objective::ValidHashrate.label(), + request.mode.label(), + ); + } + + // The other silent no-op: a temperature ceiling on a card with no + // thermometer. `within_temperature_limit` compared nothing and returned + // a pass, so "refuses a candidate past max_temp_c" did nothing at all. + // Refused here, before the sweep, because the running miner fails closed + // on a missing sensor and a tuner that pushed the card anyway would + // choose a shape the miner will then refuse to run. + let ceiling_state = TempCeiling::resolve(request.max_temp_c, sampler.measures_temperature); + wlogln!( + "[autotune] temperature: {}", + ceiling_state.describe(sampler.source) + ); + if let TempCeiling::Unenforceable { limit_c } = ceiling_state { + return Err(format!( + "[efficiency] max_temp_c is {limit_c:.0} C but nothing on this machine reports \ + this GPU's temperature ({}), so the ceiling cannot be enforced and a tune would \ + be free to pick the hottest shape on the card. Either set max_temp_c = 0 to say \ + you are not asking for one, or install a sensor this build can read: rocm-smi or \ + amd-smi for AMD, nvidia-smi for NVIDIA, or point [efficiency] thermal_file at a \ + hwmon temperature file. Config unchanged", + sampler.source + )); + } + + // On a backend that allows it the device is opened once, at the top + // corner of the whole grid, so every candidate runs against the same + // allocation and none of them is flattered by a fresh context. It is the + // whole grid rather than the planned subset on purpose: the plan is not + // known until the probe has run on this device, and an allocation that + // changed with the probe's answer would make the sweep depend on it + // twice. On a backend where a shape is baked into the allocation this + // ceiling is unused and [`Devices`] opens per shape instead. Either way + // the winner is re-opened at its own exact shape for the soak, which is + // what it will mine with. + let ceiling = Shape { + work_groups: universe.iter().map(|s| s.work_groups).max().unwrap_or(1), + local_size, + unit_size: universe.iter().map(|s| s.unit_size).max().unwrap_or(32), + }; + let probe_intro = corpus_header(0); + let mut devices = Devices::new(backend, ceiling, height, probe_intro.clone()); + + // Probe first, plan second. The corpus is sized from what this card + // really does, so a pass is a known number of seconds before anything is + // committed to, rather than a number of batches that happened to be + // affordable on the card the tuner was written on. + let probe_shape = universe + .iter() + .copied() + .min_by_key(|shape| shape.nonces()) + .unwrap_or(universe[0]); + let probe_hps = { + let device = devices.at(probe_shape)?; + probe_rate(device, probe_shape, height, &probe_intro)? + }; + let plan = plan_session( + request.min_work_groups, + request.max_work_groups, + request.max_unit_size, + local_size, + probe_hps, + request.budget_seconds, + request.headers, + NONCE_BASE, + )?; + let corpus = plan.corpus; + let usable = plan.usable.clone(); + let candidates = plan.candidates.clone(); + // Every shape this session can still ask for, coarse points and their + // refinement neighbours alike. A backend that bakes the shape into the + // allocation uses it to open once per unit_size instead of once per + // candidate; an unbound one ignores it. + devices.will_measure(&usable); + + wlogln!( + "[autotune] probe {}x{}x{}: {} -> a pass may last {:.1}s (sweep budget {}s, soak needs \ + passes under {:.1}s)", + probe_shape.work_groups, + probe_shape.local_size, + probe_shape.unit_size, + crate::bench_mainnet_repeat16::fmt_rate(probe_hps), + plan.pass_ceiling_seconds, + request.budget_seconds, + max_soak_pass_seconds(request.budget_seconds), + ); + // Named individually up to a point, then counted. On an RX 9070 XT the + // whole grid is fifteen shapes and every drop fits on screen; a card + // whose work-group ceiling is measured from its VRAM has a grid of + // seventy-five, and a wall of near-identical lines is how an operator + // learns to scroll past the ones that matter. + const NAMED_DROPS: usize = 8; + for shape in plan.over_ceiling.iter().take(NAMED_DROPS) { + wlogln!( + "[autotune] not measured: {}x{}x{} would take about {:.0} ms per batch, over the \ + {:.0} ms ceiling, so it would be refused however fast it hashed", + shape.work_groups, + shape.local_size, + shape.unit_size, + shape.nonces() as f64 / probe_hps * 1000.0, + P95_BATCH_CEILING_MS, + ); + } + if plan.over_ceiling.len() > NAMED_DROPS { + wlogln!( + "[autotune] and {} more shape(s) over the {:.0} ms batch ceiling, all larger than \ + the ones above", + plan.over_ceiling.len() - NAMED_DROPS, + P95_BATCH_CEILING_MS, + ); + } + for shape in plan.off_corpus.iter().take(NAMED_DROPS) { + wlogln!( + "[autotune] not measured: {}x{}x{} ({} nonces per batch) would push the shared \ + corpus segment past the {:.1}s a pass may take", + shape.work_groups, + shape.local_size, + shape.unit_size, + shape.nonces(), + plan.pass_ceiling_seconds, + ); + } + if plan.off_corpus.len() > NAMED_DROPS { + wlogln!( + "[autotune] and {} more shape(s) the shared corpus cannot afford", + plan.off_corpus.len() - NAMED_DROPS + ); + } + if !plan.off_corpus.is_empty() { + match plan.budget_for_every_shape { + Some(seconds) => wlogln!( + "[autotune] {} shape(s) were dropped for cost, not for correctness. To measure \ + all of them set [efficiency] benchmark_seconds = {seconds}", + plan.off_corpus.len() + ), + None => wlogln!( + "[autotune] {} shape(s) were dropped for cost. No benchmark_seconds buys them \ + back: sharing a corpus with them would need a pass longer than any soak can \ + settle on. Lower [gpu] work_groups to measure them", + plan.off_corpus.len() + ), + } + } + // The other cost that scales with the launch shape, and the one nobody + // was told about: every candidate's admission proof CPU-hashes its whole + // batch window. Say what that is going to cost before spending it. + let (proof_seconds, proof_bytes) = candidates + .iter() + .map(|shape| crate::x16rs_gate::oracle_cost(shape.nonces(), request.oracle_threads)) + .fold((0.0f64, 0u64), |(seconds, bytes), (s, b)| { + (seconds + s, bytes.max(b)) + }); + wlogln!( + "[autotune] x16rs repeat={repeat} (height {height}), local_size={local_size}, \ + {} candidate shapes of {} in the grid, corpus {} segments x {} nonces = {} nonces \ + (~{:.1}s a pass, ~{:.0}s of hashing for the sweeps)", + candidates.len(), + universe.len(), + corpus.segments, + corpus.segment_nonces, + corpus.total_nonces(), + plan.pass_seconds, + plan.sweep_seconds, + ); + wlogln!( + "[autotune] on top of that the CPU oracle proves every candidate over its whole batch \ + window: about {:.0}s on {} threads, peaking at {} MB. This is what buys the \ + consensus guarantee, and it is why shapes over the batch ceiling are not measured", + proof_seconds, + request.oracle_threads, + proof_bytes / (1024 * 1024), + ); + wlogln!( + "[autotune] estimated total before the soak: about {:.0}s", + plan.sweep_seconds + proof_seconds + ); + + let intros: Vec> = (0..corpus.headers).map(corpus_header).collect(); + + // Coarse sweep. + let mut reference: Option = None; + let mut results: Vec = Vec::new(); + for shape in &candidates { + match devices.at(*shape).and_then(|device| { + measure_candidate( + device, + *shape, + &corpus, + &intros, + height, + &sampler, + request, + reference.as_ref(), + ) + }) { + Ok(measured) => { + report_candidate(&measured, objective, request, watts_source, ""); + if reference.is_none() { + reference = Some(measured.clone()); + } + results.push(measured); + } + Err(error) => wlogln!( + "[autotune] {}x{}: REJECTED ({error})", + shape.work_groups, + shape.unit_size + ), + } + } + let fallback_watts = request.estimated_watts; + let admissible = |m: &Measured| { + score( + &m.score_input(fallback_watts), + objective, + &request.economics, + P95_BATCH_CEILING_MS, + ) + }; + let mut ranked: Vec<(&Measured, f64)> = results + .iter() + .filter_map(|m| admissible(m).map(|s| (m, s))) + .collect(); + ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); + if ranked.is_empty() { + return Err( + "no candidate produced an admissible measurement (check the latency ceiling \ + and the rejections above)" + .to_string(), + ); + } + + // Refinement: the grid points the coarse sweep skipped next to the two + // best candidates, then the finalists re-run in alternating order. Two + // rather than one because the coarse grid is half the full grid, so the + // real optimum can sit between the top two. + let mut neighbours: Vec = Vec::new(); + for (measured, _) in ranked.iter().take(2) { + for shape in refine_candidates( + measured.shape, + request.min_work_groups, + request.max_work_groups, + request.max_unit_size, + ) { + if usable.contains(&shape) + && !candidates.contains(&shape) + && !neighbours.contains(&shape) + { + neighbours.push(shape); + } + } + } + if neighbours.is_empty() { + // Not a silent skip. When the budget bought only the 2x grid, every + // point next to the winner is a 1.5x point that is not in the + // corpus, and the sweep the operator got is the whole search. + wlogln!( + "[autotune] no refinement points: every grid point next to the leaders is already \ + measured or was not in the corpus{}", + match plan.budget_for_every_shape { + Some(seconds) if !plan.off_corpus.is_empty() => + format!(", which benchmark_seconds = {seconds} would change"), + _ => String::new(), + } + ); + } + for shape in &neighbours { + match devices.at(*shape).and_then(|device| { + measure_candidate( + device, + *shape, + &corpus, + &intros, + height, + &sampler, + request, + reference.as_ref(), + ) + }) { + Ok(measured) => { + report_candidate(&measured, objective, request, watts_source, " (refine)"); + results.push(measured); + } + Err(error) => wlogln!( + "[autotune] refine {}x{}: REJECTED ({error})", + shape.work_groups, + shape.unit_size + ), + } + } + + let mut ranked: Vec<(Measured, f64)> = results + .iter() + .filter_map(|m| admissible(m).map(|s| (m.clone(), s))) + .collect(); + ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); + let finalists: Vec = ranked.iter().take(3).map(|(m, _)| m.shape).collect(); + + let mut finalist_runs: Vec = finalists + .iter() + .map(|shape| FinalistRuns { + shape: *shape, + scores: Vec::new(), + }) + .collect(); + let winner = if finalists.len() > 1 { + let rounds = 3; + wlogln!( + "[autotune] final round: {} finalists x {rounds} passes, order alternated so the \ + last one is not flattered by a hotter card. Each shape's own spread across its \ + repeats is what this comparison can resolve, and it is reported", + finalists.len() + ); + for round in 0..rounds { + let mut order: Vec = (0..finalist_runs.len()).collect(); + if round % 2 == 1 { + order.reverse(); + } + for index in order { + let shape = finalist_runs[index].shape; + let measured = devices.at(shape).and_then(|device| { + run_corpus(device, shape, &corpus, &intros, height, &sampler) + }); + match measured { + Ok(measured) => { + if let Some(reference) = reference.as_ref() { + if let Err(error) = agree_on_segments(reference, &measured) { + return Err(format!( + "final round, shape {}x{}: {error}", + shape.work_groups, shape.unit_size + )); + } + } + let input = measured.score_input(fallback_watts); + if let Some(value) = + score(&input, objective, &request.economics, P95_BATCH_CEILING_MS) + { + finalist_runs[index].scores.push(value); + } + } + Err(error) => wlogln!( + "[autotune] final round {}x{}: {error}", + shape.work_groups, + shape.unit_size + ), + } + } + } + for runs in &finalist_runs { + wlogln!( + "[autotune] final {}x{}x{}: median {} = {:.4} over {} passes, own spread \ + {:.2}%", + runs.shape.work_groups, + local_size, + runs.shape.unit_size, + objective.label(), + runs.median(), + runs.scores.len(), + runs.span_pct(), + ); + } + let resolution = comparison_resolution_pct(&finalist_runs); + match winning_margin_pct(&finalist_runs) { + Some(margin) if margin < resolution => wlogln!( + "[autotune] the top two finalists are {margin:.2}% apart and a single shape's \ + own repeats spanned {resolution:.2}%, so this round did NOT tell them apart. \ + The winner below is the larger median, and it is a coin toss between two \ + shapes that measure the same" + ), + Some(margin) => wlogln!( + "[autotune] the winner beat the runner-up by {margin:.2}% against a \ + {resolution:.2}% measurement spread, so the choice is resolved" + ), + None => {} + } + finalist_runs + .iter() + .filter(|runs| !runs.scores.is_empty()) + .max_by(|a, b| a.median().total_cmp(&b.median())) + .map(|runs| runs.shape) + .unwrap_or(finalists[0]) + } else { + finalists[0] + }; + let device_opens_in_sweep = devices.opens; + // Every allocation the sweep made is released before the soak asks for + // its own. On CUDA the sweep's miner and the soak's would otherwise both + // hold their global_hashes buffers, and the winner is usually one of the + // largest shapes measured. + drop(devices); + + // Soak, at the winner's own allocation: the buffers the miner will + // really run with, on both backends. + backend.check_shape(winner)?; + let soak_device = backend.open(winner)?; + warm_up(&soak_device, winner, height, &intros[0])?; + let (soak, settle, soak_seconds, final_measure) = soak_until_settled( + &soak_device, + winner, + &corpus, + &intros, + height, + &sampler, + request.budget_seconds, + request.max_temp_c, + )?; + if let Some(reference) = reference.as_ref() { + agree_on_segments(reference, &final_measure) + .map_err(|error| format!("after the soak: {error}"))?; + } + + // The winner is the only shape that gets written into a config and mined + // with, so it is proved again at full strength, at its own allocation, + // after the soak. The admission proof each candidate passed is sized to + // be affordable eleven times over; this one is sized to be conclusive, + // and its cost is paid once. + let final_proof = prove_shape( + &soak_device, + winner, + height, + &intros[0], + corpus.nonce_start, + FINAL_PROOF_THRESHOLDS, + request.oracle_threads, + ) + .map_err(|error| { + format!("the chosen shape failed its full-strength equivalence proof: {error}") + })?; + wlogln!( + "[autotune] chosen shape re-proved at full strength: {} thresholds over its whole \ + {}-nonce window, one wrong hash escapes with p = {:.1e}", + final_proof.thresholds, + final_proof.window, + final_proof.miss_probability + ); + drop(soak_device); + + let final_measure_watts = final_measure.telemetry.watts; + let pick = pick_for_shape( + request.vendor, + winner, + request.max_work_groups, + request.max_unit_size, + ); + Ok(TuneOutcome { + pick, + shape: winner, + backend: backend.name(), + device: backend.describe(), + // The soak's own device is counted too: it is a real allocation and + // on CUDA it is a whole extra miner. + device_opens: device_opens_in_sweep + 1, + finalists: finalist_runs, + objective, + watts_source, + soak, + soak_seconds, + settle, + winner: final_measure, + corpus, + total_seconds: started.elapsed().as_secs_f64(), + final_proof, + peak_temp_c: sampler.peak_temp(), + power_limit_w: sampler.power_limit_w, + // Measured on the WINNER's own soak window, not on the session, so + // it describes the shape the config is about to be given. + power_cap_load: sampler.power_cap_load(final_measure_watts), + ceiling: ceiling_state, + sensors: sampler.source, + plan, + }) + } + + /// Prove a shape, then measure it, then check it agrees with the reference. + #[allow(clippy::too_many_arguments)] + fn measure_candidate( + device: &D, + shape: Shape, + corpus: &Corpus, + intros: &[Vec], + height: u64, + sampler: &Sampler, + request: &TuneRequest, + reference: Option<&Measured>, + ) -> Result { + let proof = prove_shape( + device, + shape, + height, + &intros[0], + corpus.nonce_start, + request.proof_thresholds, + request.oracle_threads, + ) + .map_err(|error| format!("failed the equivalence proof: {error}"))?; + wlogln!( + "[autotune] {}x{}x{} proved against the CPU over its whole {}-nonce window: {} count \ + thresholds, one wrong hash slips past them all with p = {:.1e}", + shape.work_groups, + shape.local_size, + shape.unit_size, + proof.window, + proof.thresholds, + proof.miss_probability + ); + let measured = run_corpus(device, shape, corpus, intros, height, sampler)?; + if let TempWindow::NotMeasured { limit_c } = + within_temperature_limit(&measured.telemetry, request.max_temp_c)? + { + // The session guard already proved this card has a thermometer, so + // this is a short window the sampler did not land in, not a missing + // sensor. Said anyway: a candidate admitted without a temperature + // check is not a candidate proved to stay under the ceiling. + wlogln!( + "[autotune] {}x{}x{}: no temperature sample landed in this window, so the {:.0} C \ + ceiling was not checked for it", + shape.work_groups, + shape.local_size, + shape.unit_size, + limit_c, + ); + } + if let Some(reference) = reference { + agree_on_segments(reference, &measured)?; + } + Ok(measured) + } + + /// A shape that runs the card past the operator's ceiling is not a candidate. + /// + /// The `Ok` is a state, not a pass: `TempWindow::NotMeasured` says the + /// window carried no temperature and therefore nothing was checked. Callers + /// must not read it as a shape that stayed cool. + pub fn within_temperature_limit( + telemetry: &TelemetryWindow, + max_temp_c: Option, + ) -> Result { + temp_window_state(telemetry.peak_temp_c, max_temp_c) + } + + fn report_candidate( + measured: &Measured, + objective: Objective, + request: &TuneRequest, + watts_source: WattsSource, + suffix: &str, + ) { + let input = measured.score_input(request.estimated_watts); + let value = score(&input, objective, &request.economics, P95_BATCH_CEILING_MS); + wlogln!( + "[autotune] {}x{}x{}{}: {} | p50 {:.0}ms p95 {:.0}ms | {} {:.0}W {}C | {} = {}", + measured.shape.work_groups, + measured.shape.local_size, + measured.shape.unit_size, + suffix, + crate::bench_mainnet_repeat16::fmt_rate(measured.hashrate), + measured.p50_ms(), + measured.p95_ms(), + watts_source.label(), + input.gpu_watts, + measured + .telemetry + .temp_c + .map(|t| format!("{t:.0}")) + .unwrap_or_else(|| "?".to_string()), + objective.label(), + match value { + Some(value) => format!("{value:.4}"), + None => format!( + "REFUSED (p95 {:.0}ms over the {:.0}ms ceiling)", + measured.p95_ms(), + P95_BATCH_CEILING_MS + ), + } + ); + } + + /// Hash the corpus over and over until temperature, board power, shader + /// clock and hashrate have all stopped moving, or the cap is reached. + #[allow(clippy::too_many_arguments)] + fn soak_until_settled( + device: &D, + shape: Shape, + corpus: &Corpus, + intros: &[Vec], + height: u64, + sampler: &Sampler, + budget_seconds: u64, + max_temp_c: Option, + ) -> Result<(Vec, SettleState, f64, Measured), String> { + let limits = SettleLimits::default(); + // A soak has to be long enough for an air-cooled card to reach its + // steady temperature, which is minutes. The cap is the larger of a + // fixed floor and half the operator's budget so that a long budget + // buys a longer soak and a short one still gets a real soak. The floor + // exists because the sweep leaves the card already hot: without one, a + // shape can be declared settled on five passes taken over fifteen + // seconds, which shows that nothing changed in fifteen seconds and not + // that the shape sustains. + // + // Both come from `soak_cap_seconds` / `soak_floor_seconds` rather than + // from numbers written out here, because `plan_session` sized the corpus + // against exactly those two and the two must not be able to drift apart. + let cap = Duration::from_secs_f64(soak_cap_seconds(budget_seconds)); + let floor = Duration::from_secs_f64(soak_floor_seconds(budget_seconds)); + let started = Instant::now(); + let mut passes: Vec = Vec::new(); + let mut last: Option = None; + let mut state = SettleState::default(); + let mut unchecked_passes = 0usize; + wlogln!( + "[autotune] soak at {}x{}x{}: running until temperature, power, clock and hashrate \ + are all flat, for at least {:.0}s and at most {:.0}s; a pass has to stay under \ + {:.1}s for {} of them to fit", + shape.work_groups, + shape.local_size, + shape.unit_size, + floor.as_secs_f64(), + cap.as_secs_f64(), + max_soak_pass_seconds(budget_seconds), + soak_window_passes(), + ); + while started.elapsed() < cap { + let at = Instant::now(); + let measured = run_corpus(device, shape, corpus, intros, height, sampler)?; + let telemetry = sampler.window(at); + // A shape can pass a two-second sweep and then climb past the + // ceiling once it has been running for a minute. That is exactly + // what a soak is for, so the limit is enforced here too. + let checked = within_temperature_limit(&telemetry, max_temp_c) + .map_err(|error| format!("during the soak the chosen shape {error}"))?; + if matches!(checked, TempWindow::NotMeasured { .. }) { + unchecked_passes += 1; + } + passes.push(SoakPass { + seconds: measured.seconds, + hashrate: measured.hashrate, + p95_ms: measured.p95_ms(), + temp_c: telemetry.temp_c, + watts: telemetry.watts, + clock_mhz: telemetry.clock_mhz, + }); + last = Some(measured); + state = settle_state(&passes, &limits); + if state.settled && started.elapsed() >= floor { + break; + } + } + let elapsed = started.elapsed().as_secs_f64(); + let measured = last.ok_or_else(|| "the soak completed no passes".to_string())?; + let absent = state.absent_signals(); + if !absent.is_empty() { + wlogln!( + "[autotune] the soak judged flatness on the hashrate alone where it had to: this \ + card reports no {}. \"Settled\" here is a weaker claim than on a card that \ + reports all four", + absent.join(", no ") + ); + } + if unchecked_passes > 0 { + wlogln!( + "[autotune] {unchecked_passes} of the {} soak passes carried no temperature \ + sample, so the ceiling was not checked over them", + passes.len() + ); + } + if !state.settled { + // Say which of the two it was, because the remedies are opposite. A + // soak that ran out of passes needs a shorter corpus (which is the + // planner's job and should never happen now); a soak that made its + // passes and stayed noisy needs more time. + if passes.len() < soak_window_passes() { + wlogln!( + "[autotune] the soak fitted only {} of the {} passes it needs inside {:.0}s: \ + a pass took {:.1}s against the {:.1}s the plan plans for. This is the corpus \ + being too long for the budget, not the card being unstable", + passes.len(), + soak_window_passes(), + cap.as_secs_f64(), + passes.last().map(|p| p.seconds).unwrap_or(0.0), + max_soak_pass_seconds(budget_seconds), + ); + } else { + wlogln!( + "[autotune] the soak made {} passes in {elapsed:.0}s and the card was still \ + moving: hashrate span {:.2}%. This one really is answered by a larger \ + benchmark_seconds", + passes.len(), + state.rate_span_pct, + ); + } + } + Ok((passes, state, elapsed, measured)) + } + + /// The human-readable proof block. + pub fn render(outcome: &TuneOutcome, request: &TuneRequest) -> String { + let input = outcome.winner.score_input(request.estimated_watts); + let mut text = format!( + " backend / device : {} / {}\n \ + devices opened : {} ({})\n \ + workload : x16rs repeat = {} (height {}), the same rounds the live chain runs\n \ + corpus : {} segments x {} nonces = {} nonces, headers {}, nonce base {}\n \ + every candidate hashed exactly these nonces against exactly these headers\n \ + chosen shape : work_groups={} local_size={} unit_size={} ({} nonces per batch)\n \ + profile written : {}\n \ + objective : {} ({} mode) on {} watts\n \ + sustained : {} raw, {} after the {:.2}% a template change throws away\n \ + batch latency : p50 {:.0} ms, p95 {:.0} ms (ceiling {:.0} ms)\n \ + board power : {}\n \ + temperature : {} while running, {} at the end of the soak\n \ + ceiling : {}\n \ + equivalence : {} count thresholds over the whole {}-nonce launch window; one \ +wrong hash escapes with p = {:.1e}\n \ + CPU verification : {} batches re-hashed with x16rs::block_hash, byte-equal\n", + outcome.backend, + outcome.device, + outcome.device_opens, + if outcome.device_opens > 2 { + "this backend bakes the launch shape into the allocation, so a candidate with a \ + new unit_size needs its own device; each one is warmed up before it is timed" + } else { + "one allocation served the sweep, plus the winner's own for the soak" + }, + x16rs::block_hash_repeat(crate::x16rs_gate::REPEAT16_HEIGHT), + crate::x16rs_gate::REPEAT16_HEIGHT, + outcome.corpus.segments, + outcome.corpus.segment_nonces, + outcome.corpus.total_nonces(), + outcome.corpus.headers, + outcome.corpus.nonce_start, + outcome.shape.work_groups, + outcome.shape.local_size, + outcome.shape.unit_size, + outcome.shape.nonces(), + outcome.pick.profile, + outcome.objective.label(), + request.mode.label(), + outcome.watts_source.label(), + crate::bench_mainnet_repeat16::fmt_rate(outcome.winner.hashrate), + crate::bench_mainnet_repeat16::fmt_rate(input.valid_hps()), + stale_fraction(input.mean_batch_seconds) * 100.0, + outcome.winner.p50_ms(), + outcome.winner.p95_ms(), + P95_BATCH_CEILING_MS, + match outcome.winner.telemetry.watts { + Some(watts) => format!( + "{watts:.0} W {} ({} samples)", + outcome.watts_source.label(), + outcome.winner.telemetry.samples + ), + None => format!( + "{:.0} W estimated, this card reports none", + request.estimated_watts + ), + }, + outcome + .peak_temp_c + .map(|t| format!("peaked at {t:.0} C")) + .unwrap_or_else(|| "not measured".to_string()), + outcome + .winner + .telemetry + .temp_c + .map(|t| format!("{t:.0} C")) + .unwrap_or_else(|| "not measured".to_string()), + outcome.ceiling.describe(outcome.sensors), + outcome.final_proof.thresholds, + outcome.final_proof.window, + outcome.final_proof.miss_probability, + outcome.winner.cpu_checks, + ); + // Said in the proof block and not only in the log, because the log + // scrolls and this is the sentence that decides whether the operator's + // chosen mode meant anything. + if outcome.watts_source == WattsSource::Estimated + && outcome.objective != Objective::ValidHashrate + { + text.push_str( + " mode not honoured: nothing here reports this card's power draw, so every \ + candidate was\n scored on the same estimated watts and this \ + ranking is identical to max mode's\n", + ); + } + if let Some(net) = input.net_eur_per_day(&request.economics) { + text.push_str(&format!(" net : {net:.4} EUR/day\n")); + } + // Why the winning shape won, where the card itself supplies the reason. + // + // A card sitting on its power cap and a card starved of work in flight + // want OPPOSITE things from unit_size, and both have been measured under + // this kernel: an RX 9070 XT gains about 9% going from 64 to 192, while a + // Tesla T4 at 66 W against a 70 W cap loses going the same way, because + // the larger batch cannot draw more power, only hold the card at the + // limit for longer. The tuner finds either optimum from the hashrate + // alone. This line is so the operator is not left to guess which regime + // their card is in, and does not "fix" a correct answer by hand. + if let Some(load) = outcome.power_cap_load { + text.push_str(&format!( + " power limit : {}\n", + if load >= POWER_CAPPED_FRACTION { + format!( + "the winner drew {:.0}% of this card's {:.0} W cap, so it is POWER CAPPED. \ + A larger\n launch cannot buy more work here, it only \ + holds the card at the limit for\n longer; expect the \ + smaller unit_size to win and read the batch latency above", + load * 100.0, + outcome.power_limit_w.unwrap_or_default(), + ) + } else { + format!( + "the winner drew {:.0}% of this card's {:.0} W cap, so it is not power \ + limited", + load * 100.0, + outcome.power_limit_w.unwrap_or_default(), + ) + } + )); + } + text.push_str(&format!( + " soak : {} passes over {:.0}s, {}\n \ + p95 batch {:.0} ms on the first pass, {:.0} ms on the last\n \ + hashrate span {:.2}%{}{}{}\n", + outcome.soak.len(), + outcome.soak_seconds, + if outcome.settle.settled { + "settled" + } else { + "DID NOT SETTLE within the cap; the numbers above are the last pass" + }, + // The tail is what a template change waits on, and it is what grows + // when a card that started cold settles onto a power or thermal + // limit. Reported at both ends of the soak so the growth is visible + // rather than averaged away. + outcome.soak.first().map(|pass| pass.p95_ms).unwrap_or(0.0), + outcome.soak.last().map(|pass| pass.p95_ms).unwrap_or(0.0), + outcome.settle.rate_span_pct, + outcome + .settle + .temp_span_c + .map(|v| format!(", temperature span {v:.1} C")) + .unwrap_or_default(), + outcome + .settle + .watts_span_pct + .map(|v| format!(", power span {v:.2}%")) + .unwrap_or_default(), + outcome + .settle + .clock_span_pct + .map(|v| format!(", clock span {v:.2}%")) + .unwrap_or_default(), + )); + // What "settled" did NOT cover. Without this line a card that reports + // nothing but a hashrate produces the same word as one that held its + // temperature, its watts and its clock flat for five passes. + let absent = outcome.settle.absent_signals(); + if !absent.is_empty() { + text.push_str(&format!( + " not part of that judgement, this card reports none: {}\n", + absent.join(", ") + )); + } + text.push_str(&format!( + " search space : {} of {} shapes measured; {} could not meet the batch ceiling, \ + {} cost more than a {:.1}s pass{}\n", + outcome.plan.candidates.len(), + outcome.plan.candidates.len() + + outcome.plan.over_ceiling.len() + + outcome.plan.off_corpus.len(), + outcome.plan.over_ceiling.len(), + outcome.plan.off_corpus.len(), + outcome.plan.pass_ceiling_seconds, + match outcome.plan.budget_for_every_shape { + Some(seconds) if !outcome.plan.off_corpus.is_empty() => + format!(" (benchmark_seconds = {seconds} would measure them)"), + _ => String::new(), + } + )); + text.push_str(&format!( + " planned / actual : sweep {:.0}s planned, {:.0}s of tune in total\n", + outcome.plan.sweep_seconds, outcome.total_seconds + )); + text.push_str(&resolution_note(&outcome.finalists, outcome.backend)); + text + } +} + +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub use device::{ + Measured, POWER_CAPPED_FRACTION, ShapeProof, TelemetryWindow, TuneOutcome, TuneRequest, + TuneTarget, agree_on_segments, prove_shape, render, run_corpus, tune, tune_on, + within_temperature_limit, +}; + +/// The last thing a candidate has to survive before its number is believed: +/// hashing the same work as the reference and agreeing with it hash for hash. +/// +/// Exposed outside `device` so the corpus tests can name it. +#[cfg(any(feature = "ocl", feature = "cuda", test))] +pub fn coverage_matches(corpus: &Corpus, reference: Shape, other: Shape) -> Result<(), String> { + let want = corpus.coverage_signature(reference)?; + let got = corpus.coverage_signature(other)?; + if want != got { + return Err(format!( + "shape {}x{}x{} covers the corpus as {want:?}, shape {}x{}x{} as {got:?}", + reference.work_groups, + reference.local_size, + reference.unit_size, + other.work_groups, + other.local_size, + other.unit_size + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shape(work_groups: u32, unit_size: u32) -> Shape { + Shape { + work_groups, + local_size: 256, + unit_size, + } + } + + /// Every (header_index, nonce) pair `shape` will hash, in the order it will + /// hash them. The literal object the corpus is supposed to hold constant. + fn expand(corpus: &Corpus, shape: Shape) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + for batch in corpus.batches(shape).unwrap() { + for offset in 0..batch.nonces { + out.push((batch.header_index, batch.nonce_start + offset as u32)); + } + } + out + } + + #[test] + fn every_candidate_hashes_exactly_the_same_header_and_nonce_pairs() { + // This is the property the whole module exists for: two shapes with very + // different batch sizes must cover the identical multiset of work, or + // the faster-looking one may simply have drawn cheaper algorithms. + let shapes = vec![shape(32, 32), shape(48, 96), shape(64, 192)]; + let (corpus, usable, dropped) = + plan_corpus(&shapes, 0x2000_0000, 4, 4, 1 << 21, 1 << 27).unwrap(); + assert!(dropped.is_empty(), "no shape should have been dropped"); + assert_eq!(usable.len(), shapes.len()); + + let reference = expand(&corpus, shapes[0]); + assert_eq!(reference.len() as u64, corpus.total_nonces()); + for other in &shapes[1..] { + assert_eq!( + reference, + expand(&corpus, *other), + "shape {other:?} does not hash the same work as {:?}", + shapes[0] + ); + } + } + + /// The closed form is allowed to stand in for the expansion only because it + /// is the same statement. + /// + /// A real corpus is tens of millions of nonces and a real card offers up to + /// forty-five shapes, so the pair-by-pair comparison above cannot be run on + /// what the tuner actually plans: it is gigabytes per shape. Every other + /// test therefore compares `coverage_signature`. This test is what makes + /// that legitimate: over every grid the presets can produce, scaled down by + /// taking `local_size = 1` so the pairs fit in memory, signatures are equal + /// exactly when the expansions are equal, and unequal exactly when they are + /// not. + /// + /// `local_size` is a common factor of every batch on a device, so scaling it + /// changes every batch by the same factor and leaves the divisibility + /// structure the corpus is built on identical. Nothing about the tiling + /// depends on its value. + #[test] + fn identical_coverage_is_exactly_an_identical_signature() { + let mut compared = 0usize; + for (min_wg, max_wg, max_us) in [ + (32u32, 64u32, 192u32), // the RX 9070 XT window, as shipped + (256, 1024, 128), // an rx6600 / rtx4060 / arc_a380 window + (256, 2048, 128), // an rx6800xt / arc_a770 window + (256, 256, 128), // the collapsed single work-group window + ] { + // local_size 1, so a whole corpus is a few hundred thousand pairs. + let universe = candidate_universe(min_wg, max_wg, max_us, 1); + let (corpus, usable, _) = + plan_corpus(&universe, 0, 3, 3, 1, u32::MAX as u64 / 4).unwrap(); + let reference = usable[0]; + let want = expand(&corpus, reference); + assert_eq!(want.len() as u64, corpus.total_nonces()); + for other in &usable { + let got = expand(&corpus, *other); + let signatures_agree = corpus.coverage_signature(reference).unwrap() + == corpus.coverage_signature(*other).unwrap(); + assert_eq!( + got == want, + signatures_agree, + "{min_wg}..={max_wg} x {max_us}: shape {other:?} expands to {} pairs and its \ + signature {} the reference's; the two forms disagree", + got.len(), + if signatures_agree { + "equals" + } else { + "differs from" + } + ); + assert!( + got == want, + "shape {other:?} does not hash the reference's work" + ); + assert!(coverage_matches(&corpus, reference, *other).is_ok()); + compared += 1; + } + + // And the negative half: a corpus placed somewhere else, or cut into + // a different number of segments, must make both forms disagree + // together. Without this the test would pass on a signature function + // that returned a constant. + let moved = Corpus { + nonce_start: corpus.nonce_start + corpus.segment_nonces as u32, + ..corpus + }; + assert_ne!(expand(&moved, reference), want); + assert_ne!( + moved.coverage_signature(reference).unwrap(), + corpus.coverage_signature(reference).unwrap() + ); + } + assert!(compared >= 40, "only {compared} shapes were compared"); + } + + /// A signature is only worth anything because the coverage it summarises is + /// checked to be a gapless, non-overlapping, in-order cover as it is built. + #[test] + fn a_coverage_with_a_gap_or_an_overlap_is_refused_rather_than_summarised() { + let corpus = Corpus { + nonce_start: 1_000, + headers: 2, + segment_nonces: 4_096, + segments: 4, + }; + let fits = Shape { + work_groups: 1, + local_size: 1, + unit_size: 1_024, + }; + assert!(corpus.coverage_signature(fits).is_ok()); + // Four segments, two headers, so the signature is four runs and not one: + // consecutive nonces under different headers must not be merged. + assert_eq!(corpus.coverage_signature(fits).unwrap().len(), 4); + assert_eq!( + corpus + .coverage_signature(fits) + .unwrap() + .iter() + .map(|run| run.0) + .collect::>(), + vec![0, 1, 0, 1] + ); + // A batch that does not divide the segment cannot produce a cover at + // all, so there is nothing to summarise. + let does_not_tile = Shape { + work_groups: 1, + local_size: 1, + unit_size: 3_000, + }; + assert!(corpus.coverage_signature(does_not_tile).is_err()); + } + + #[test] + fn a_shape_that_cannot_tile_the_corpus_is_refused_not_truncated() { + let corpus = Corpus { + nonce_start: 0, + headers: 2, + segment_nonces: 1_000, + segments: 2, + }; + let odd = shape(3, 7); + assert!(!corpus.fits(odd)); + assert!(corpus.batches(odd).is_err()); + } + + #[test] + fn the_corpus_quantum_is_the_least_common_multiple_and_is_capped() { + assert_eq!(shared_segment_nonces(&[4, 6], 1, 1_000), Some(12)); + assert_eq!(shared_segment_nonces(&[4, 6], 100, 1_000), Some(108)); + assert_eq!(shared_segment_nonces(&[4, 6], 1, 11), None); + // A batch size with a large prime factor is what blows the quantum up. + assert_eq!(shared_segment_nonces(&[1 << 20, 7 << 20], 1, 1 << 22), None); + } + + #[test] + fn planning_drops_the_shape_that_forces_the_quantum_up_and_says_which() { + // The awkward shape has the SMALLER batch (216 832 against 262 144), so + // a planner that dropped by size would keep it and throw away the useful + // one. What matters is that 121 = 11^2 multiplies the quantum by 847. + let good = shape(32, 32); + let awkward = Shape { + work_groups: 7, + local_size: 256, + unit_size: 121, + }; + assert!(awkward.nonces() < good.nonces()); + let (corpus, usable, dropped) = + plan_corpus(&[good, awkward], 0, 1, 1, 1 << 20, 1 << 24).unwrap(); + assert_eq!(dropped, vec![awkward]); + assert_eq!(usable, vec![good]); + assert!(corpus.fits(good)); + assert!(!corpus.fits(awkward)); + } + + #[test] + fn both_tuning_axes_stay_on_the_grid_that_keeps_the_corpus_small() { + // Every point must be 2^a or 3*2^a. One value with a factor of 5 or 7 + // multiplies the corpus quantum by that factor for every candidate. + let dyadic = |value: u32| { + let mut v = value; + while v % 2 == 0 { + v /= 2; + } + v == 1 || v == 3 + }; + for (min, max) in [(32u32, 64u32), (256, 2048), (1, 1), (100, 100)] { + for value in work_group_grid(min, max) { + assert!(value >= min.min(max) && value <= max); + if value != max { + assert!(dyadic(value), "{value} is not 2^a or 3*2^a"); + } + } + } + assert_eq!(work_group_grid(32, 64), vec![32, 48, 64]); + assert_eq!(unit_size_grid(192), vec![32, 48, 64, 96, 128, 192]); + // A window containing no grid point still yields the one shape the + // device can actually run. + assert_eq!(work_group_grid(100, 100), vec![100]); + } + + #[test] + fn the_coarse_sweep_is_half_the_grid_and_refinement_fills_the_gaps() { + let coarse = coarse_candidates(32, 64, 192, 256); + let universe = candidate_universe(32, 64, 192, 256); + assert!( + coarse.len() < universe.len(), + "the coarse pass must be coarse" + ); + assert!(coarse.iter().all(|shape| universe.contains(shape))); + // Refining around any coarse point reaches only grid points, and reaches + // at least one the coarse pass skipped. + let base = coarse[coarse.len() / 2]; + let refined = refine_candidates(base, 32, 64, 192); + assert!(refined.iter().all(|shape| universe.contains(shape))); + assert!(refined.iter().any(|shape| !coarse.contains(shape))); + } + + #[test] + fn stale_work_is_priced_from_the_block_interval() { + // A one-second batch at a 300-second block target throws away half a + // second every time the job changes. + assert!((stale_fraction(1.0) - 1.0 / 600.0).abs() < 1e-12); + assert_eq!(stale_fraction(0.0), 0.0); + assert_eq!(stale_fraction(f64::NAN), 0.0); + // A slower shape with a much longer batch can lose to a faster one. + let short = sustained_valid_hps(1_000_000.0, 0.03); + let long = sustained_valid_hps(1_002_000.0, 60.0); + assert!(short > long, "{short} vs {long}"); + } + + #[test] + fn a_shape_over_the_latency_ceiling_is_refused_however_fast_it_is() { + let econ = Economics::default(); + let fast_but_laggy = ScoreInput { + hashrate: 100e6, + mean_batch_seconds: 3.0, + p95_batch_ms: 4_000.0, + gpu_watts: 200.0, + }; + assert_eq!( + score( + &fast_but_laggy, + Objective::ValidHashrate, + &econ, + P95_BATCH_CEILING_MS + ), + None + ); + let ordinary = ScoreInput { + p95_batch_ms: 40.0, + ..fast_but_laggy + }; + assert!( + score( + &ordinary, + Objective::ValidHashrate, + &econ, + P95_BATCH_CEILING_MS + ) + .is_some() + ); + } + + #[test] + fn eco_ranks_on_measured_joules_and_max_ranks_on_hashes() { + let econ = Economics { + cpu_watts: 0.0, + ..Economics::default() + }; + let big = ScoreInput { + hashrate: 19e6, + mean_batch_seconds: 0.03, + p95_batch_ms: 35.0, + gpu_watts: 291.0, + }; + let small = ScoreInput { + hashrate: 6e6, + mean_batch_seconds: 0.03, + p95_batch_ms: 35.0, + gpu_watts: 156.0, + }; + let s = |input: &ScoreInput, objective| { + score(input, objective, &econ, P95_BATCH_CEILING_MS).unwrap() + }; + assert!(s(&big, Objective::ValidHashrate) > s(&small, Objective::ValidHashrate)); + // 19/291 = 65 kH/J against 6/156 = 38 kH/J, so Max and Eco agree here. + assert!(s(&big, Objective::HashesPerJoule) > s(&small, Objective::HashesPerJoule)); + // A hypothetical shape that buys 5% more hashes for 60% more watts is + // the case the two objectives must disagree on. + let greedy = ScoreInput { + hashrate: 19.95e6, + gpu_watts: 465.0, + ..big + }; + assert!(s(&greedy, Objective::ValidHashrate) > s(&big, Objective::ValidHashrate)); + assert!(s(&greedy, Objective::HashesPerJoule) < s(&big, Objective::HashesPerJoule)); + } + + #[test] + fn profit_needs_a_price_for_a_hash_and_says_so_when_it_has_none() { + let priced = Economics { + power_cost_kwh: 0.30, + hac_price: 2.0, + hac_per_hps_day: Some(1e-9), + cpu_watts: 0.0, + }; + assert_eq!( + resolve_objective(EfficiencyMode::Profit, &priced), + (Objective::NetIncome, None) + ); + let no_difficulty = Economics { + hac_per_hps_day: None, + ..priced + }; + let (objective, reason) = resolve_objective(EfficiencyMode::Profit, &no_difficulty); + assert_eq!(objective, Objective::ValidHashrate); + assert!(reason.unwrap().contains("network difficulty")); + let no_price = Economics { + hac_price: 0.0, + ..no_difficulty + }; + let (objective, reason) = resolve_objective(EfficiencyMode::Profit, &no_price); + assert_eq!(objective, Objective::ValidHashrate); + assert!(reason.unwrap().contains("hac_price")); + // Eco and Max never depend on a price. + assert_eq!( + resolve_objective(EfficiencyMode::Eco, &no_price).0, + Objective::HashesPerJoule + ); + assert_eq!( + resolve_objective(EfficiencyMode::Max, &no_price).0, + Objective::ValidHashrate + ); + } + + #[test] + fn net_income_prefers_the_shape_that_earns_more_than_it_burns() { + // Electricity at a price where the extra 174 W costs more than the extra + // 0.95 MH/s earns, so Profit must pick the smaller shape even though Max + // would not. + let econ = Economics { + power_cost_kwh: 1.0, + hac_price: 1.0, + hac_per_hps_day: Some(1e-9), + cpu_watts: 0.0, + }; + let modest = ScoreInput { + hashrate: 19e6, + mean_batch_seconds: 0.03, + p95_batch_ms: 35.0, + gpu_watts: 291.0, + }; + let greedy = ScoreInput { + hashrate: 19.95e6, + gpu_watts: 465.0, + ..modest + }; + let net = |input: &ScoreInput| input.net_eur_per_day(&econ).unwrap(); + assert!( + net(&modest) > net(&greedy), + "{} vs {}", + net(&modest), + net(&greedy) + ); + assert!(greedy.valid_hps() > modest.valid_hps()); + } + + #[test] + #[cfg(feature = "ocl")] + fn a_shape_that_runs_the_card_past_the_operators_ceiling_is_not_a_candidate() { + // The peak, not the mean: a shape that averages 78 C by spending part of + // the run at 91 C has been above an 85 C ceiling. + let hot = TelemetryWindow { + temp_c: Some(78.0), + peak_temp_c: Some(91.0), + watts: Some(336.0), + clock_mhz: Some(3_300.0), + samples: 40, + }; + let error = within_temperature_limit(&hot, Some(85.0)).unwrap_err(); + assert!(error.contains("91"), "{error}"); + assert!(error.contains("max_temp_c"), "{error}"); + assert_eq!( + within_temperature_limit(&hot, Some(95.0)).unwrap(), + TempWindow::Under { + peak_c: 91.0, + limit_c: 95.0 + } + ); + // No ceiling set: nothing to enforce, and never a refusal invented out + // of a missing measurement. + assert_eq!( + within_temperature_limit(&hot, None).unwrap(), + TempWindow::NoCeiling + ); + // A ceiling set and nothing measured is its own answer. It used to be + // `Ok(())`, indistinguishable from a shape that stayed cool, which is + // how "refuses a candidate past max_temp_c" did nothing on a card with + // no thermometer. + assert_eq!( + within_temperature_limit( + &TelemetryWindow { + peak_temp_c: None, + ..hot + }, + Some(60.0) + ) + .unwrap(), + TempWindow::NotMeasured { limit_c: 60.0 } + ); + } + + /// An absent thermometer is a state the tune refuses on, not a satisfied + /// ceiling. + /// + /// This is defect 2 in full: `detect_gpu_temp_sensor` returns `None` for + /// Intel and Unknown, so `Sampler` samples nothing, every window's + /// `peak_temp_c` is `None`, and the old check compared nothing and passed. + #[test] + fn a_ceiling_with_no_thermometer_is_never_silently_satisfied() { + // Intel: no source at all, so nothing on this machine could enforce it. + let intel = TempCeiling::resolve(Some(85.0), false); + assert_eq!(intel, TempCeiling::Unenforceable { limit_c: 85.0 }); + assert!(!intel.is_enforceable()); + let said = intel.describe("no GPU sensor on this machine"); + assert!(said.contains("CANNOT BE ENFORCED"), "{said}"); + assert!(said.contains("max_temp_c"), "{said}"); + assert!(said.contains("85"), "{said}"); + + // The same card with no ceiling asked for is not a problem and must not + // be reported as one. + assert_eq!(TempCeiling::resolve(None, false), TempCeiling::NotRequested); + assert!(TempCeiling::resolve(None, false).is_enforceable()); + assert!( + !TempCeiling::resolve(None, false) + .describe("no GPU sensor on this machine") + .contains("CANNOT") + ); + + // A card with a thermometer enforces it, and says which sensor does. + let amd = TempCeiling::resolve(Some(85.0), true); + assert_eq!(amd, TempCeiling::Enforced { limit_c: 85.0 }); + assert!(amd.is_enforceable()); + assert!( + amd.describe("AMD driver (ADL)") + .contains("AMD driver (ADL)") + ); + + // And every window state is distinguishable, so no caller can read + // "nothing was measured" as "it stayed under". + assert_eq!( + temp_window_state(None, Some(85.0)).unwrap(), + TempWindow::NotMeasured { limit_c: 85.0 } + ); + assert_ne!( + temp_window_state(None, Some(85.0)).unwrap(), + temp_window_state(Some(70.0), Some(85.0)).unwrap() + ); + assert!(temp_window_state(Some(85.1), Some(85.0)).is_err()); + assert_eq!( + temp_window_state(Some(85.0), Some(85.0)).unwrap(), + TempWindow::Under { + peak_c: 85.0, + limit_c: 85.0 + }, + "the ceiling is a limit, not an exclusive bound" + ); + } + + #[test] + fn settling_needs_every_sensor_the_card_has_to_be_flat() { + let limits = SettleLimits::default(); + let flat: Vec = (0..6) + .map(|i| SoakPass { + seconds: 1.0, + hashrate: 19_000_000.0 + i as f64 * 1_000.0, + p95_ms: 320.0, + temp_c: Some(76.0), + watts: Some(291.0), + clock_mhz: Some(3_300.0), + }) + .collect(); + assert!(settle_state(&flat, &limits).settled); + + // Still climbing in temperature: not settled, even though the hashrate + // has stopped moving. This is the case the old 5-second verification + // could not see. + let mut climbing = flat.clone(); + for (i, pass) in climbing.iter_mut().enumerate() { + pass.temp_c = Some(60.0 + i as f32 * 3.0); + } + assert!(!settle_state(&climbing, &limits).settled); + + // Power still ramping. + let mut ramping = flat.clone(); + for (i, pass) in ramping.iter_mut().enumerate() { + pass.watts = Some(200.0 + i as f32 * 20.0); + } + assert!(!settle_state(&ramping, &limits).settled); + + // Clock still boosting down. + let mut boosting = flat.clone(); + for (i, pass) in boosting.iter_mut().enumerate() { + pass.clock_mhz = Some(3_400.0 - i as f32 * 40.0); + } + assert!(!settle_state(&boosting, &limits).settled); + + // Too few passes is never settled. + assert!(!settle_state(&flat[..2], &limits).settled); + + // A card with no power or clock sensor still settles on what it has, + // and says which signals it did not have. Settling on them is not + // optional: an NVIDIA card with no board-power sensor would otherwise + // soak to the cap on every single run. Saying so is, and it is the + // difference between "settled over four signals" and "settled over one" + // wearing the same word. + let sparse: Vec = flat + .iter() + .map(|pass| SoakPass { + watts: None, + clock_mhz: None, + ..*pass + }) + .collect(); + let sparse_state = settle_state(&sparse, &limits); + assert!(sparse_state.settled); + assert_eq!( + sparse_state.absent_signals(), + vec!["board power", "shader clock"] + ); + + // An Intel card: nothing but a hashrate. + let blind: Vec = sparse + .iter() + .map(|pass| SoakPass { + temp_c: None, + ..*pass + }) + .collect(); + let blind_state = settle_state(&blind, &limits); + assert!(blind_state.settled); + assert_eq!( + blind_state.absent_signals(), + vec!["temperature", "board power", "shader clock"] + ); + + // A card that reported all four claims nothing extra. + assert!(settle_state(&flat, &limits).absent_signals().is_empty()); + } + + #[test] + fn the_candidate_grid_stays_inside_the_device_limits() { + // RX 9070 XT: work groups capped at 64, unit size at 192. + let candidates = coarse_candidates(32, 64, 192, 256); + assert!(!candidates.is_empty()); + assert!( + candidates + .iter() + .all(|s| s.work_groups >= 32 && s.work_groups <= 64 && s.unit_size <= 192) + ); + assert!(candidates.iter().any(|s| s.work_groups == 64)); + assert!(candidates.iter().any(|s| s.unit_size == 32)); + // A device whose cap is below a grid point must not be offered it. + let small = coarse_candidates(32, 64, 64, 256); + assert!(small.iter().all(|s| s.unit_size <= 64)); + } + + /// Same rule the tuner uses; duplicated here rather than exported because a + /// test that computed the cap by calling the code under test would pass + /// whatever that code did. + fn cap_for(universe: &[Shape], nonce_start: u32) -> u64 { + let largest = universe.iter().map(|s| s.nonces()).max().unwrap(); + (largest * 32).min((u32::MAX as u64 - nonce_start as u64) / 4) + } + + #[test] + fn every_device_shape_this_tuner_can_reach_shares_one_corpus() { + // Not just this card. If any of these device classes starts dropping + // candidates, the grid is wrong and some operator's tune silently + // measures fewer shapes than it reports. + for (min_wg, max_wg, max_us) in [ + (32u32, 64u32, 192u32), // RX 9070 XT / RDNA4 + (256, 2048, 128), // a large AMD or NVIDIA card + (256, 1024, 96), // a small discrete card + (256, 512, 128), // an Intel Arc + ] { + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + let (corpus, usable, dropped) = plan_corpus( + &universe, + 0x2000_0000, + 4, + 4, + 1 << 21, + cap_for(&universe, 0x2000_0000), + ) + .unwrap(); + assert!( + dropped.is_empty(), + "device {min_wg}..{max_wg} x {max_us} dropped {dropped:?}" + ); + assert_eq!(usable.len(), universe.len()); + assert!(usable.iter().all(|shape| corpus.fits(*shape))); + // And the corpus still fits the 32-bit nonce space it is placed in. + assert!(corpus.batches(usable[0]).is_ok()); + } + } + + #[test] + fn the_corpus_segment_stays_a_handful_of_batches_on_this_card() { + // The quantum is what decides how short a candidate's measurement can + // be. On the 9070 XT grid it must stay within a few batches of the + // largest shape, or every measurement is padded with work nobody needs. + let universe = candidate_universe(32, 64, 192, 256); + let (corpus, _, _) = plan_corpus( + &universe, + 0x2000_0000, + 4, + 4, + 1 << 21, + cap_for(&universe, 0x2000_0000), + ) + .unwrap(); + // Both axes are 2^a or 3*2^a, so every batch is 2^k*3^j with j <= 2 and + // the quantum cannot exceed nine times the largest batch. Measured on + // this grid it is six times: 64x128 contributes the 2^13 and 48x192 the + // 3^2. + let largest = universe.iter().map(|s| s.nonces()).max().unwrap(); + assert!( + corpus.segment_nonces <= largest * 9, + "segment {} against a largest batch of {largest}", + corpus.segment_nonces + ); + assert_eq!(corpus.segment_nonces, 18_874_368); + } + + #[test] + fn percentiles_are_nearest_rank_and_survive_short_samples() { + let sorted = vec![10.0, 20.0, 30.0, 40.0, 100.0]; + assert_eq!(percentile(&sorted, 0.0), 10.0); + assert_eq!(percentile(&sorted, 0.5), 30.0); + assert_eq!(percentile(&sorted, 0.95), 100.0); + assert_eq!(percentile(&[], 0.5), 0.0); + assert_eq!(median(&[3.0, 1.0, 2.0]), 2.0); + assert_eq!(median(&[4.0, 1.0, 2.0, 3.0]), 2.5); + } + + #[test] + fn the_written_profile_reflects_how_much_of_the_card_the_shape_uses() { + let vendor = crate::gpu_arch::GpuVendor::Amd; + let full = profile_for_shape(vendor, shape(64, 192), 64, 192); + let tiny = profile_for_shape(vendor, shape(32, 32), 64, 192); + assert_eq!(full, "amd_max"); + assert_eq!(tiny, "amd_eco"); + let pick = pick_for_shape(vendor, shape(48, 96), 64, 192); + assert_eq!(pick.workgroups, 48); + assert_eq!(pick.unitsize, 96); + } + + // ----------------------------------------------------------------------- + // Every card the code knows, driven through the candidate generator. + // + // The tuner was measured on one card. These tests exist so that the search + // space every OTHER card gets is at least checked arithmetically: that it + // is not empty, not a single point, not so coarse that the tune is a + // formality, and not so expensive that it cannot finish. + // ----------------------------------------------------------------------- + + /// Compute-unit counts to drive each preset with. + /// + /// `initialize_opencl` runs the configured work_groups through + /// `tune_workgroups`, which scales by the device's compute units, so the + /// number the tuner sees is not the number the panel wrote. 8 is an Arc + /// A310/A380, 170 is an RTX 5090; the rest are between. + const COMPUTE_UNITS: [u32; 9] = [8, 16, 20, 32, 40, 60, 84, 128, 170]; + + /// Bytes of device state per nonce in flight. + /// + /// `buffer_global_hashes` is 32 bytes per nonce and `buffer_global_order` is + /// 4, both sized `unit_size * work_groups * local_size`; everything else the + /// context holds is a fixed handful of kilobytes. See + /// `opencl_gpu::resources`. This is the number that makes 64x256x192 come + /// to the 113 MB quoted on `ArchLimits::max_unit_size`. + const DEVICE_BYTES_PER_NONCE: u64 = 36; + + /// The window `poworker::run_block_mining_benchmark` hands the generator, + /// for one card in one mode on a device with `compute_units` CUs. + /// + /// It reproduces those four lines rather than calling them, because they sit + /// behind an OpenCL probe that needs a device. + fn tuner_window( + slug: &str, + base_profile: &str, + vram_gb: u8, + mode: EfficiencyMode, + compute_units: u32, + ) -> (u32, u32, u32) { + use crate::gpu_arch::{ArchLimits, profile_vendor, tune_workgroups}; + + let limits = ArchLimits::for_panel_slug(slug); + let shipped = crate::panel_tuning::resolve_panel_tuning(slug, base_profile, vram_gb, mode); + // What the probe reports, having applied CU scaling and the arch cap. + // The VRAM clamp inside `initialize_opencl` can only lower this further, + // and lowering it is covered by the small-CU end of the sweep. + let probe_wg = tune_workgroups( + shipped.work_groups, + compute_units, + profile_vendor(base_profile), + limits, + ); + ( + limits.panel_min_wg.min(probe_wg), + probe_wg, + limits.max_unit_size().max(32), + ) + } + + /// Plan the corpus over a window exactly as `tune` does, and return what the + /// operator would actually get. + fn plan_window(min_wg: u32, max_wg: u32, max_us: u32) -> (Corpus, Vec, Vec) { + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + plan_corpus( + &universe, + 0x2000_0000, + 4, + 4, + 1 << 21, + cap_for(&universe, 0x2000_0000), + ) + .unwrap_or_else(|e| panic!("window {min_wg}..={max_wg} x {max_us}: {e}")) + } + + /// No card, in any mode, on any plausible device, gets a search space that + /// is empty, a single point, or outside the limits it was built from. + #[test] + fn every_card_gets_a_search_space_worth_sweeping() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + for (slug, profile, vram) in PANEL_GPU_PRESETS { + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + for cu in COMPUTE_UNITS { + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, cu); + let where_ = + format!("{slug} {mode:?} {cu} CU ({min_wg}..={max_wg} x {max_us})"); + + assert!(min_wg >= 1 && min_wg <= max_wg, "{where_}: inverted window"); + + let coarse = coarse_candidates(min_wg, max_wg, max_us, 256); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + assert!(!coarse.is_empty(), "{where_}: nothing to sweep"); + assert!(!universe.is_empty(), "{where_}: empty universe"); + + // Three is the arithmetic floor: the unit_size axis always + // offers 32/64/128 whatever the card, so a card can lose its + // whole work-group axis and still have something to compare. + // Anything below that means the generator broke. + assert!( + coarse.len() >= 3, + "{where_}: only {} candidates", + coarse.len() + ); + assert!(universe.len() >= coarse.len(), "{where_}"); + + for shape in &universe { + assert!( + shape.work_groups >= min_wg && shape.work_groups <= max_wg, + "{where_}: {shape:?} outside the work-group window" + ); + assert!( + shape.unit_size >= 32 && shape.unit_size <= max_us, + "{where_}: {shape:?} outside the unit-size window" + ); + assert!(shape.nonces() > 0, "{where_}: {shape:?} hashes nothing"); + } + for shape in &coarse { + assert!(universe.contains(shape), "{where_}: {shape:?} not planned"); + } + // The top of both axes is always reachable, so no card is + // stopped short of its own ceiling by the grid. + assert!( + coarse.iter().any(|s| s.work_groups + == *universe.iter().map(|u| &u.work_groups).max().unwrap()), + "{where_}: the coarse sweep never reaches the top work-group count" + ); + assert!( + coarse.iter().any(|s| s.unit_size == max_us), + "{where_}: the coarse sweep never reaches unit_size {max_us}" + ); + } + } + } + } + + /// Whatever the card, the shared corpus must actually exist, must be tileable + /// by every candidate the sweep will measure, and must fit the nonce space. + #[test] + fn every_card_gets_a_corpus_its_coarse_sweep_can_share() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + for (slug, profile, vram) in PANEL_GPU_PRESETS { + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + for cu in COMPUTE_UNITS { + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, cu); + let where_ = + format!("{slug} {mode:?} {cu} CU ({min_wg}..={max_wg} x {max_us})"); + // Planned the way `tune` plans it, at the one rate anyone + // has ever measured for this kernel. + let plan = plan_session( + min_wg, + max_wg, + max_us, + 256, + MEASURED_9070XT_HPS, + PANEL_BUDGET_SECONDS, + 4, + NONCE_BASE, + ) + .unwrap_or_else(|e| panic!("{where_}: {e}")); + + assert!(plan.is_a_comparison(), "{where_}: nothing to compare"); + // Everything the sweep will measure has to tile the corpus, + // cover it exactly, and fit the 32-bit nonce space it is + // placed in. `coverage_signature` checks all three, and every + // candidate's has to be the reference's. + let reference = plan.candidates[0]; + for shape in &plan.candidates { + assert!( + plan.corpus.fits(*shape), + "{where_}: {shape:?} cannot tile the corpus" + ); + coverage_matches(&plan.corpus, reference, *shape) + .unwrap_or_else(|e| panic!("{where_}: {e}")); + } + // And every refinement point, since refinement measures on + // the same frozen corpus. + for shape in &plan.usable { + coverage_matches(&plan.corpus, reference, *shape) + .unwrap_or_else(|e| panic!("{where_}: refinement point {e}")); + } + // A shape is only ever dropped for one of two stated + // reasons, and never silently. + let planned = + plan.candidates.len() + plan.over_ceiling.len() + plan.off_corpus.len(); + let coarse = coarse_candidates(min_wg, max_wg, max_us, 256); + assert!( + planned >= coarse.len(), + "{where_}: {} coarse shapes went missing without a reason", + coarse.len() - plan.candidates.len() + ); + } + } + } + } + + /// The corpus quantum is the least common multiple of every candidate's + /// batch, so it decides how much work a candidate is forced to do before it + /// can be compared with another. If it runs away, tuning that card takes + /// absurdly long or the planner starts throwing candidates out. + /// + /// Both tuning axes are 2^a or 3*2^a, which bounds the quantum at nine times + /// the largest batch on every card. That bound is the invariant; the numbers + /// below are what it comes to in practice. + #[test] + fn the_corpus_quantum_stays_within_nine_batches_on_every_card() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + for (slug, profile, vram) in PANEL_GPU_PRESETS { + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + for cu in COMPUTE_UNITS { + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, cu); + let where_ = format!("{slug} {mode:?} {cu} CU"); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + let largest = universe.iter().map(|s| s.nonces()).max().unwrap(); + let (corpus, _, _) = plan_window(min_wg, max_wg, max_us); + assert!( + corpus.segment_nonces <= largest * 9, + "{where_}: quantum {} against a largest batch of {largest}", + corpus.segment_nonces + ); + } + } + } + + // The 9x bound is real and it is not enough on its own. These are the + // quanta the unbounded plan produces, in nonces, for the two ends of the + // preset table. The RX 9070 XT's is 18.9 M, about 0.65 s at its measured + // 28.8 MH/s. The largest NVIDIA preset's is 151 M, eight times as much, + // because the bound is 9x a batch that is itself eight times bigger. + // That is why `plan_session` caps the quantum in seconds and not in + // batches: the grid's own arithmetic cannot bound it in a way that + // survives a card with a wide work-group ceiling. + // + // 151 M was 604 M until the NVIDIA presets were derived rather than + // guessed. `nvidia_max` named 3584 work groups, which on the one NVIDIA + // card ever measured is 90 waves of a kernel that holds exactly one + // resident block per multiprocessor, and a 15.6-second batch against a + // 1.5-second latency ceiling. It now names 768. The quantum is a + // consequence of that, not a target: see `nvidia_launch::PRESET_LADDER`. + let quantum_for = |slug: &str, mode: EfficiencyMode, cu: u32| -> u64 { + let (_, profile, vram) = PANEL_GPU_PRESETS + .iter() + .find(|(s, _, _)| *s == slug) + .copied() + .unwrap(); + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, cu); + plan_window(min_wg, max_wg, max_us).0.segment_nonces + }; + assert_eq!(quantum_for("rx9070xt", EfficiencyMode::Max, 32), 18_874_368); + assert_eq!( + quantum_for("rtx5090", EfficiencyMode::Max, 170), + 150_994_944 + ); + } + + /// Refinement may only ever ask for points the corpus was planned around, + /// and only inside the device's window. + #[test] + fn refinement_never_leaves_the_planned_universe() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + for (slug, profile, vram) in PANEL_GPU_PRESETS { + for cu in COMPUTE_UNITS { + let (min_wg, max_wg, max_us) = + tuner_window(slug, profile, vram, EfficiencyMode::Max, cu); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + for base in &universe { + let refined = refine_candidates(*base, min_wg, max_wg, max_us); + assert!( + refined.contains(base), + "{slug} {cu} CU: refining {base:?} lost the point it started from" + ); + for shape in &refined { + assert!( + universe.contains(shape), + "{slug} {cu} CU: refining {base:?} proposed {shape:?}, which the \ + corpus was never planned for" + ); + } + } + } + } + } + + /// `max_wg` is the work_groups the device is ALREADY configured with, so the + /// tuner's window is `[min(panel floor, configured), configured]`. It can + /// lower work_groups and it can never raise them, on any card in the table. + /// + /// This is the one structural limit the RX 9070 XT result does not + /// generalise past. That card's +50% came from raising unit_size, an axis + /// whose ceiling comes from `ArchLimits` rather than from the running + /// config, and in Eco and Profit it has room there. A card whose shipped + /// unit_size is already at its ceiling has no room on either axis: its + /// current shape is the top corner of the search space and Auto Tune can + /// only confirm it or shrink it. + #[test] + fn the_tuner_can_never_search_above_the_configured_work_groups() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + let mut boxed_in = Vec::new(); + for (slug, profile, vram) in PANEL_GPU_PRESETS { + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + let shipped = crate::panel_tuning::resolve_panel_tuning(slug, profile, vram, mode); + for cu in COMPUTE_UNITS { + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, cu); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + assert!( + universe.iter().all(|s| s.work_groups <= max_wg), + "{slug} {mode:?}: something above the configured work_groups" + ); + assert!( + !universe.iter().any(|s| s.work_groups > shipped.work_groups), + "{slug} {mode:?} {cu} CU: the work-group axis reached above the \ + configured {}. Good news if deliberate: update this test.", + shipped.work_groups + ); + } + + // At the card's own CU count the unit_size axis is the only one + // that can still go up. Record the modes where it cannot. + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, 170); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + if !universe.iter().any(|s| s.unit_size > shipped.unit_size) { + boxed_in.push(format!("{slug} {mode:?}")); + } + } + } + + // Every one of these ships at the top corner of its own search space. + // Auto Tune on them is a strictly downward search: it cannot reproduce + // the RX 9070 XT result, because that result was a bigger shape. + assert_eq!( + boxed_in, + vec![ + "rx6600 Eco", + "rx6600 Profit", + "rx7600 Eco", + "rx7600 Profit", + "rx6800xt Max", + "rx7900xt Max", + "rx7900xtx Profit", + "rx7900xtx Max", + "rx9070xt Max", + "arc_a380 Profit", + "arc_a770 Max", + ], + "the set of cards that cannot search upward on either axis changed" + ); + + // Ten NVIDIA entries used to be on that list and none is now, which is + // the point of deriving the presets rather than guessing them. + // + // The old table put every NVIDIA tier at unit_size 96 or 128 while the + // axis ends at 128, so a tune on those cards could only confirm the + // shipped value or move DOWN from it - and on the one NVIDIA card + // anyone has measured the answer is down, so that direction was at + // least the useful one. It was still a search space with the shipped + // shape wedged in its corner, which is the same defect the RX 9070 XT + // had in the other direction and which cost that card 50% for a year. + // At unit_size 64 the axis runs 32 and 48 below and 96 and 128 above, + // so a card nobody has measured is bracketed from both sides. + assert!( + !boxed_in.iter().any(|entry| entry.starts_with("rtx")), + "an NVIDIA card is back in the corner of its own search space: {boxed_in:?}" + ); + for (slug, profile, vram) in PANEL_GPU_PRESETS { + if crate::gpu_arch::profile_vendor(profile) != crate::gpu_arch::GpuVendor::Nvidia { + continue; + } + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + let shipped = crate::panel_tuning::resolve_panel_tuning(slug, profile, vram, mode); + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, 170); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + assert!( + universe.iter().any(|s| s.unit_size < shipped.unit_size), + "{slug} {mode:?}: nothing below the shipped unit_size {}", + shipped.unit_size + ); + assert!( + universe.iter().any(|s| s.unit_size > shipped.unit_size), + "{slug} {mode:?}: nothing above the shipped unit_size {}", + shipped.unit_size + ); + assert_eq!( + shipped.unit_size, + crate::nvidia_launch::MEASURED_T4_BEST_UNIT_SIZE, + "{slug} {mode:?} does not ship at the one measured NVIDIA optimum" + ); + } + } + } + + /// The narrowest space any shipped card gets, named so it cannot regress + /// quietly. + /// + /// An Arc A310/A380 has 8 Xe cores. `tune_workgroups` scales the panel's 384 + /// down to 8 x 32 = 256, which is also `panel_min_wg`, so the work-group + /// window collapses to a single value and the tune becomes a three point + /// sweep of the unit_size axis alone. That is still a real comparison, but + /// it is one axis, and it is the floor of the other. + #[test] + fn the_smallest_intel_card_tunes_on_one_axis() { + let (min_wg, max_wg, max_us) = + tuner_window("arc_a380", "intel_balanced", 6, EfficiencyMode::Eco, 8); + assert_eq!((min_wg, max_wg, max_us), (256, 256, 128)); + assert_eq!(work_group_grid(min_wg, max_wg), vec![256]); + let coarse = coarse_candidates(min_wg, max_wg, max_us, 256); + assert_eq!(coarse.len(), 3); + assert!(coarse.iter().all(|s| s.work_groups == 256)); + assert_eq!( + coarse.iter().map(|s| s.unit_size).collect::>(), + vec![32, 64, 128] + ); + } + + /// Whatever a future `ArchLimits` hands the generator, including windows no + /// card has today, it must still produce something to measure. + /// + /// The interesting case is the degenerate one. A window of a single work + /// group is survivable, because the unit_size axis carries the comparison; + /// a window of a single work group AND a unit_size ceiling of 32 is not, + /// because it yields one candidate and a tune of one candidate is not a + /// comparison, it is a report. What keeps that unreachable is the unit_size + /// ceiling: `max_unit_size()` is 128 or 192 and `poworker` floors it at 32, + /// so the sweep always has at least the three unit sizes 32, 64 and 128. + /// The second half of this test is what makes that argument load bearing. + #[test] + fn no_device_window_however_odd_produces_an_empty_grid() { + for max_us in [32u32, 33, 48, 64, 96, 100, 128, 192, 256] { + for max_wg in [1u32, 2, 7, 32, 48, 63, 64, 100, 256, 1000, 4096, 8192] { + for min_wg in [1u32, 32, 100, 256, 512, 4096] { + let min_wg = min_wg.min(max_wg); + let coarse = coarse_candidates(min_wg, max_wg, max_us, 256); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + let where_ = format!("{min_wg}..={max_wg} x {max_us}"); + assert!(!coarse.is_empty(), "{where_}: no coarse candidates"); + assert!(!universe.is_empty(), "{where_}: no universe"); + assert!( + coarse.iter().all(|s| s.nonces() > 0), + "{where_}: a candidate hashes nothing" + ); + assert!( + coarse.iter().all(|s| universe.contains(s)), + "{where_}: a coarse candidate is not in the universe" + ); + if max_us >= 128 { + assert!( + coarse.len() >= 3, + "{where_}: {} candidates is not a sweep", + coarse.len() + ); + } + } + } + } + + // The unit_size ceiling can never fall into the range where the grid + // degenerates, for any slug the detector can produce or any panel + // preset the user can pick. + use crate::gpu_arch::{ArchLimits, KNOWN_ARCH_SLUGS, PANEL_GPU_PRESETS}; + for slug in KNOWN_ARCH_SLUGS { + assert!(ArchLimits::for_slug(slug).max_unit_size() >= 128, "{slug}"); + } + for (slug, _, _) in PANEL_GPU_PRESETS { + assert!(ArchLimits::panel_max_unit_size(slug) >= 128, "{slug}"); + } + assert!(ArchLimits::for_slug("some_future_card").max_unit_size() >= 128); + } + + /// Opening the device at the top of the search space must not be an + /// allocation the card cannot make. The tuner opens once at the ceiling of + /// the whole universe, so that shape, not the winner, is what has to fit. + #[test] + fn the_largest_launch_a_card_can_be_asked_for_fits_its_vram() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + for (slug, profile, vram) in PANEL_GPU_PRESETS { + let vram_bytes = vram as u64 * 1024 * 1024 * 1024; + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + for cu in COMPUTE_UNITS { + let (min_wg, max_wg, max_us) = tuner_window(slug, profile, vram, mode, cu); + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + let biggest = universe.iter().map(|s| s.nonces()).max().unwrap(); + let bytes = biggest * DEVICE_BYTES_PER_NONCE; + assert!( + bytes * 4 <= vram_bytes, + "{slug} {mode:?} {cu} CU: the ceiling launch wants {} MB of a {vram} GB \ + card, leaving no room for the driver and the display", + bytes / (1024 * 1024) + ); + } + } + } + + // A card with no preset gets its ceiling from VRAM alone, and the + // smallest bracket must still be safe on the smallest card in it. + use crate::gpu_arch::ArchLimits; + for vram in [2u8, 4, 8, 16, 24, 32, 48] { + let max_wg = ArchLimits::panel_max_work_groups("some_future_card", vram); + let max_us = ArchLimits::panel_max_unit_size("some_future_card"); + let bytes = max_wg as u64 * 256 * max_us as u64 * DEVICE_BYTES_PER_NONCE; + let vram_bytes = vram as u64 * 1024 * 1024 * 1024; + if vram >= 4 { + assert!( + bytes * 2 <= vram_bytes, + "{vram} GB fallback wants {} MB", + bytes / (1024 * 1024) + ); + } + } + } + + /// The window the PANEL's Auto Tune button really hands the tuner. + /// + /// `miner-panel::config::write_poworker_benchmark_config` rewrites `[gpu]` + /// `work_groups` to `ArchLimits::panel_max_work_groups` and `unit_size` to + /// `panel_max_unit_size` before it runs poworker, so the benchmark window is + /// the card's whole safe range, not the shape the miner is currently set to. + /// `tuner_window` above models the other entry point, a hand-written ini. + fn panel_button_window( + slug: &str, + base_profile: &str, + vram_gb: u8, + compute_units: u32, + ) -> (u32, u32, u32) { + use crate::gpu_arch::{ArchLimits, profile_vendor, tune_workgroups}; + + let limits = ArchLimits::for_panel_slug(slug); + let probe_wg = tune_workgroups( + ArchLimits::panel_max_work_groups(slug, vram_gb), + compute_units, + profile_vendor(base_profile), + limits, + ); + ( + limits.panel_min_wg.min(probe_wg), + probe_wg, + limits.max_unit_size().max(32), + ) + } + + /// `soak_until_settled`'s loop, with every pass assumed perfectly flat. + /// + /// Best case for the card: nothing here models a clock ramp or a warming + /// fan, only the arithmetic of how many passes fit. If this says no, no real + /// card can do better. + fn soak_can_settle(pass_seconds: f64, budget_seconds: u64) -> bool { + let cap = (budget_seconds as f64 * 0.5).max(90.0).min(900.0); + let floor = 45.0f64.min(cap); + let window = SettleLimits::default().window; + let mut elapsed = 0.0; + let mut passes = 0usize; + while elapsed < cap { + elapsed += pass_seconds; + passes += 1; + if passes >= window && elapsed >= floor { + return true; + } + } + false + } + + /// The single measured x16rs repeat-16 GPU rate in this repository: + /// 64x256x192 on an RX 9070 XT. See `ArchLimits::max_unit_size`. + const MEASURED_9070XT_HPS: f64 = 28.8e6; + + /// What the panel writes into `[efficiency] benchmark_seconds` before it + /// runs poworker. + const PANEL_BUDGET_SECONDS: u64 = 90; + + /// The lowest hashrate at which a card with this window can complete a tune: + /// plan it, sweep it, and reach the soak's settling window. + /// + /// Found by bisection on the real planner rather than by a formula, because + /// the planner's answer is not monotone in an obvious way: a slower card + /// gets a smaller quantum cap, which drops more shapes, which shrinks the + /// quantum, which shortens the pass. Bisection over 60 halvings resolves the + /// boundary to well under a hash per second either side of it. + fn lowest_finishing_hps(min_wg: u32, max_wg: u32, max_us: u32, budget: u64) -> Option { + let finishes = |hps: f64| -> bool { + match plan_session(min_wg, max_wg, max_us, 256, hps, budget, 4, NONCE_BASE) { + Ok(plan) => { + plan.is_a_comparison() + && plan.soak_can_settle(budget) + && soak_can_settle(plan.pass_seconds, budget) + } + Err(_) => false, + } + }; + let (mut low, mut high) = (1.0e3, 1.0e9); + if !finishes(high) { + return None; + } + for _ in 0..60 { + let middle = (low + high) / 2.0; + if finishes(middle) { + high = middle; + } else { + low = middle; + } + } + Some(high) + } + + /// Every card in the table can finish a tune at a rate it could plausibly + /// have, which is the thing the previous corpus made impossible. + /// + /// The chain the old corpus failed on is arithmetic, not opinion: + /// + /// * a candidate can only be measured on a whole number of its own + /// batches, so the corpus segment is the l.c.m. of every candidate's + /// batch, which the 2^a / 3*2^a grid bounds at 9x the largest batch; + /// * the largest batch scales with `max_work_groups`, which is 64 on + /// gfx1201 and 1024 to 4096 everywhere else; + /// * `soak_until_settled` begins a pass only while `elapsed < cap`, so + /// five passes need four of them to fit inside + /// `max(budget*0.5, 90).min(900)`: under 22.5 s each on the panel's + /// 90 s budget; + /// * a pass that does not fit leaves `settle.settled` false, and + /// `poworker::run_block_mining_benchmark` then writes nothing. + /// + /// Sizing the segment from the largest candidate and then forcing four of + /// them per pass put that requirement at 3.4 MH/s for the RX 9070 XT and 54 + /// to 215 MH/s for every other preset, against the 28.8 MH/s that is the + /// only rate anyone has measured. `plan_session` sizes the segment from the + /// clock instead, so the requirement is now set by the smallest launch a + /// device offers rather than by the largest one it permits. + #[test] + fn every_card_can_finish_a_tune_at_a_rate_it_could_plausibly_have() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + // The bound. 10 MH/s is a third of the one measured rate and below any + // discrete GPU in this table; a card slower than this is not a card the + // presets describe. Every preset must be under it with room to spare. + const DEFENSIBLE_BOUND_HPS: f64 = 10.0e6; + + let mut worst: (f64, String) = (0.0, String::new()); + for (slug, profile, vram) in PANEL_GPU_PRESETS { + // Both entry points: the panel's Auto Tune button, which opens the + // window to the card's whole safe range, and a hand-written ini. + // And the high-CU end, which is where these cards sit; a low CU + // count only shrinks the window and helps. + for cu in COMPUTE_UNITS { + let windows = [ + ("panel button", panel_button_window(slug, profile, vram, cu)), + ( + "hand-written ini", + tuner_window(slug, profile, vram, EfficiencyMode::Max, cu), + ), + ]; + for (entry, (min_wg, max_wg, max_us)) in windows { + let where_ = format!("{slug} {entry} {cu} CU ({min_wg}..={max_wg} x {max_us})"); + let required = + lowest_finishing_hps(min_wg, max_wg, max_us, PANEL_BUDGET_SECONDS) + .unwrap_or_else(|| { + panic!("{where_}: no hashrate at all completes a tune") + }); + assert!( + required < DEFENSIBLE_BOUND_HPS, + "{where_}: needs {:.2} MH/s before a tune can finish", + required / 1e6 + ); + if required > worst.0 { + worst = (required, where_); + } + } + } + } + // Named, so a change that quietly doubles it fails here rather than in + // an operator's log. 1.86 MH/s is where it sits, on an RX 7900 XTX + // opened to 256..=3840 work groups. + assert!( + worst.0 < 2.5e6, + "the hardest preset now needs {:.2} MH/s ({})", + worst.0 / 1e6, + worst.1 + ); + + // The same arithmetic under the corpus this replaces, computed here from + // the same windows so the improvement is measured rather than asserted. + // The old plan's pass was four segments of the l.c.m. of the whole + // universe, and it had to fit `max_soak_pass_seconds`. + for (slug, profile, vram) in PANEL_GPU_PRESETS { + let (min_wg, max_wg, max_us) = panel_button_window(slug, profile, vram, 128); + let quantum = plan_window(min_wg, max_wg, max_us).0.segment_nonces; + let was = (quantum * 4) as f64 / max_soak_pass_seconds(PANEL_BUDGET_SECONDS); + let now = lowest_finishing_hps(min_wg, max_wg, max_us, PANEL_BUDGET_SECONDS).unwrap(); + if slug == "rx9070xt" { + // The one card this was validated on is not made worse: it + // needed 3.4 MH/s and it still needs a fraction of that. + assert!((was - 3.355e6).abs() < 1.0e4, "{slug}: was {was}"); + assert!(now < was, "{slug}: {now} is not below {was}"); + } else { + assert!( + was > 25.0e6, + "{slug} only needed {:.1} MH/s before; the defect being fixed has moved", + was / 1e6 + ); + assert!( + now * 10.0 < was, + "{slug}: {:.1} MH/s required before, {:.2} MH/s now, which is not the order \ + of magnitude this change is supposed to be", + was / 1e6, + now / 1e6 + ); + } + } + + // And at the one rate anyone has measured, every preset finishes. + let mut cannot: Vec<&str> = Vec::new(); + for (slug, profile, vram) in PANEL_GPU_PRESETS { + let (min_wg, max_wg, max_us) = panel_button_window(slug, profile, vram, 128); + let plan = plan_session( + min_wg, + max_wg, + max_us, + 256, + MEASURED_9070XT_HPS, + PANEL_BUDGET_SECONDS, + 4, + NONCE_BASE, + ); + let finished = plan.is_ok_and(|p| { + p.is_a_comparison() && soak_can_settle(p.pass_seconds, PANEL_BUDGET_SECONDS) + }); + if !finished { + cannot.push(slug); + } + } + assert_eq!( + cannot, + Vec::<&str>::new(), + "cards that still could not finish a tune at the one measured rate" + ); + } + + // ----------------------------------------------------------------------- + // The NVIDIA grid: can the corpus planner finish on it? + // ----------------------------------------------------------------------- + + /// Way 1. Bisection on the real planner, judged by the planner's own + /// opinion of whether the soak can settle. + fn required_hps_from_the_planner( + min_wg: u32, + max_wg: u32, + max_us: u32, + budget: u64, + ) -> Option { + bisect_required(|hps| { + plan_session(min_wg, max_wg, max_us, 256, hps, budget, 4, NONCE_BASE) + .is_ok_and(|plan| plan.is_a_comparison() && plan.soak_can_settle(budget)) + }) + } + + /// Way 2. Bisection on the real planner, judged by an independent + /// re-implementation of `soak_until_settled`'s loop. + /// + /// `SessionPlan::soak_can_settle` is a closed-form inequality; the free + /// `soak_can_settle` above steps the loop pass by pass. They are two + /// different pieces of code and this is what makes them answer the same + /// question about the same plan. + fn required_hps_from_the_soak_loop( + min_wg: u32, + max_wg: u32, + max_us: u32, + budget: u64, + ) -> Option { + bisect_required(|hps| { + plan_session(min_wg, max_wg, max_us, 256, hps, budget, 4, NONCE_BASE).is_ok_and( + |plan| plan.is_a_comparison() && soak_can_settle(plan.pass_seconds, budget), + ) + }) + } + + /// Way 3. Algebra, with no planner in it at all. + /// + /// A tune is a comparison, so at least two shapes have to survive together, + /// and `plan_session` puts three separate gates between a shape and the + /// measurement. Write all three down for the two cheapest shapes a device + /// offers, which are its smallest work-group count at unit_size 32 and at + /// 64: batches B and 2B, l.c.m. 2B. + /// + /// (a) **The latency prune.** A shape is dropped before anything is + /// measured unless its batch fits + /// `P95_BATCH_CEILING_MS * LATENCY_HEADROOM` at the probed rate. The + /// second shape is the one that has to get through, so + /// `hps >= 2B / (1.5 * 1.6)`. On the windows the NVIDIA presets + /// produce this is the gate that binds, which is worth knowing: the + /// corpus is not what a slow card runs out of, the p95 ceiling is. + /// (b) **The quantum cap.** The segment is a common multiple of the kept + /// batches and may not exceed a pass, so `hps >= 2B / pass_ceiling` + /// unless `MIN_SEGMENT_NONCES` already covers it. + /// (c) **The soak.** One segment is the smallest corpus and the pass has + /// to fit `max_soak_pass_seconds`. + /// + /// The requirement is the largest of the three. Everything the planner adds + /// on top of this keeps MORE shapes and makes the corpus bigger, so this is + /// a lower bound on ways 1 and 2, and the test asserts that relationship + /// rather than assuming it. + fn required_hps_closed_form(min_wg: u32, max_wg: u32, max_us: u32, budget: u64) -> f64 { + let grid = work_group_grid(min_wg, max_wg); + let wg0 = grid.first().copied().unwrap_or(min_wg) as u64; + let units = unit_size_grid(max_us); + // The two cheapest points that are powers of two, which is the family + // `coarse_axis` chooses and `plan_corpus` keeps. + let mut pair: Vec = units + .iter() + .copied() + .filter(|u| u.is_power_of_two()) + .take(2) + .map(u64::from) + .collect(); + if pair.len() < 2 { + pair = units.iter().take(2).map(|u| u64::from(*u)).collect(); + } + let batches: Vec = pair.iter().map(|unit| wg0 * 256 * unit).collect(); + let second = batches.iter().copied().max().unwrap_or(1); + let quantum = batches + .iter() + .fold(1u64, |acc, batch| lcm(acc, *batch).unwrap_or(u64::MAX)); + let segment = quantum * MIN_SEGMENT_NONCES.div_ceil(quantum).max(1); + + // (a) the latency prune has to admit the second shape. + let by_latency = second as f64 / ((P95_BATCH_CEILING_MS / 1000.0) * LATENCY_HEADROOM); + // (b) the quantum cap has to hold their common multiple. Below + // MIN_SEGMENT_NONCES the cap's own floor already does. + let expected_passes = coarse_candidates(min_wg, max_wg, max_us, 256).len() as f64 + + REFINE_PASS_ALLOWANCE as f64 + + FINAL_PASS_ALLOWANCE as f64; + let sweep_pass = budget as f64 * SWEEP_BUDGET_SHARE / expected_passes; + let pass_ceiling = sweep_pass.min(max_soak_pass_seconds(budget) * SOAK_PASS_MARGIN); + let by_quantum = if quantum <= MIN_SEGMENT_NONCES { + 0.0 + } else { + quantum as f64 / pass_ceiling + }; + // (c) the pass has to settle. + let by_soak = segment as f64 / max_soak_pass_seconds(budget); + + by_latency.max(by_quantum).max(by_soak) + } + + fn bisect_required(finishes: impl Fn(f64) -> bool) -> Option { + let (mut low, mut high) = (1.0e3, 1.0e9); + if !finishes(high) { + return None; + } + for _ in 0..60 { + let middle = (low + high) / 2.0; + if finishes(middle) { + high = middle; + } else { + low = middle; + } + } + Some(high) + } + + /// Multiprocessor counts of the NVIDIA cards in the panel table, which is + /// what `tune_workgroups` scales the preset by. Kept next to the slugs it + /// belongs to in `nvidia_launch`, and asserted equal there. + fn nvidia_sm_count(slug: &str) -> Option { + crate::nvidia_launch::NVIDIA_PANEL_SM_COUNTS + .iter() + .find(|(entry, _)| *entry == slug) + .map(|(_, sms)| *sms) + } + + /// Every NVIDIA card, both entry points, three ways of asking what hashrate + /// a tune needs before it can finish. A T4 does 7.54 MH/s. + /// + /// This is the NVIDIA half of + /// `every_card_can_finish_a_tune_at_a_rate_it_could_plausibly_have`, done + /// separately because the NVIDIA grid is now derived from the hardware + /// (`nvidia_launch`) rather than from the preset table, and because a + /// requirement above about 2 MH/s on a card that does 7.54 is a design + /// error rather than a tight fit. It prints the table, so + /// `cargo test -- --nocapture nvidia_grid` is the audit. + #[test] + fn the_corpus_planner_finishes_on_every_nvidia_grid_three_ways() { + use crate::gpu_arch::{GpuVendor, PANEL_GPU_PRESETS, profile_vendor}; + + // A T4 sustains 7.54 MH/s. A tune that needs more than this fraction of + // it is a tune that cannot run on the one NVIDIA card ever measured. + const NVIDIA_BOUND_HPS: f64 = 2.0e6; + + let mut rows: Vec = Vec::new(); + let mut worst: (f64, String) = (0.0, String::new()); + let mut checked = 0usize; + + println!( + "\n{:<10} {:<18} {:>4} {:>16} {:>9} {:>9} {:>9}", + "card", "entry", "SMs", "window", "planner", "soakloop", "algebra" + ); + for (slug, profile, vram) in PANEL_GPU_PRESETS { + if profile_vendor(profile) != GpuVendor::Nvidia { + continue; + } + let sms = nvidia_sm_count(slug) + .unwrap_or_else(|| panic!("{slug} has no multiprocessor count")); + // Both entry points, driven at the card's real multiprocessor count + // as well as at the extremes of the CU sweep, because + // `tune_workgroups` scales the preset by it. + for cu in [8u32, sms, 170] { + let windows = [ + ("panel button", panel_button_window(slug, profile, vram, cu)), + ( + "hand-written ini", + tuner_window(slug, profile, vram, EfficiencyMode::Max, cu), + ), + ]; + for (entry, (min_wg, max_wg, max_us)) in windows { + let where_ = format!("{slug} {entry} {cu} CU ({min_wg}..={max_wg} x {max_us})"); + let planner = + required_hps_from_the_planner(min_wg, max_wg, max_us, PANEL_BUDGET_SECONDS) + .unwrap_or_else(|| panic!("{where_}: no hashrate finishes a tune")); + let soak_loop = required_hps_from_the_soak_loop( + min_wg, + max_wg, + max_us, + PANEL_BUDGET_SECONDS, + ) + .unwrap_or_else(|| panic!("{where_}: no hashrate finishes a tune")); + let algebra = + required_hps_closed_form(min_wg, max_wg, max_us, PANEL_BUDGET_SECONDS); + + // The two bisections ask different code the same question. + // They resolve to well under a hash per second, so anything + // beyond rounding between them is a real disagreement about + // when a soak settles. + assert!( + (planner - soak_loop).abs() / planner < 1e-6, + "{where_}: the planner says {planner:.0} H/s and the soak loop says \ + {soak_loop:.0} H/s; two pieces of code disagree about settling" + ); + // And the algebra is a floor: everything the planner adds + // makes the corpus bigger, never smaller. + assert!( + algebra <= planner * 1.000_001, + "{where_}: the closed form wants {algebra:.0} H/s, ABOVE the planner's \ + {planner:.0}. One of the two is modelling the wrong corpus" + ); + + for (way, value) in [ + ("planner", planner), + ("soak loop", soak_loop), + ("algebra", algebra), + ] { + assert!( + value < NVIDIA_BOUND_HPS, + "{where_}: {way} needs {:.2} MH/s, and a Tesla T4 does 7.54", + value / 1e6 + ); + } + if planner > worst.0 { + worst = (planner, where_.clone()); + } + rows.push(format!( + "{:<10} {:<18} {:>4} {:>16} {:>8.2}M {:>8.2}M {:>8.2}M", + slug, + entry, + cu, + format!("{min_wg}..{max_wg}x{max_us}"), + planner / 1e6, + soak_loop / 1e6, + algebra / 1e6 + )); + checked += 1; + } + } + } + for row in &rows { + println!("{row}"); + } + println!( + "worst NVIDIA requirement {:.2} MH/s ({}), against 7.54 MH/s measured on a T4\n", + worst.0 / 1e6, + worst.1 + ); + + assert!(checked >= 50, "only {checked} NVIDIA windows were checked"); + // Named so a change that quietly doubles it fails here rather than in an + // operator's log. + // + // 1.75 MH/s is where every window the PRESETS produce sits, and it is + // not the corpus: it is gate (a), the latency prune needing to admit + // 256 x 256 x 64, whose 4 194 304-nonce batch has to come in under + // 1.5 s x 1.6. 1.86 is the panel Auto Tune button on a 4090 or 5090, + // whose window is deliberately the card's whole safe range (3584 work + // groups) rather than the preset. A T4 does 7.54 MH/s, so the tightest + // of these leaves a factor of four. + assert!( + worst.0 < 2.0e6, + "the hardest NVIDIA window now needs {:.2} MH/s ({})", + worst.0 / 1e6, + worst.1 + ); + // The windows the presets themselves produce are the tighter number, + // and they are the ones a change to `nvidia_launch::PRESET_LADDER` + // would move. + let preset_worst = PANEL_GPU_PRESETS + .iter() + .filter(|(_, profile, _)| profile_vendor(profile) == GpuVendor::Nvidia) + .flat_map(|(slug, profile, vram)| { + [8u32, 170].into_iter().map(move |cu| { + let (a, b, c) = tuner_window(slug, profile, *vram, EfficiencyMode::Max, cu); + required_hps_from_the_planner(a, b, c, PANEL_BUDGET_SECONDS).unwrap() + }) + }) + .fold(0.0f64, f64::max); + assert!( + preset_worst < 1.8e6, + "the NVIDIA preset windows need {:.2} MH/s", + preset_worst / 1e6 + ); + + // And end to end at the rate the card really does: a full plan, at the + // T4's own multiprocessor count and its own measured hashrate. + let (min_wg, max_wg, max_us) = ( + crate::nvidia_launch::work_group_floor(crate::nvidia_launch::MEASURED_T4_SM_COUNT, 1), + 7_387, + 128, + ); + let plan = plan_session( + min_wg, + max_wg, + max_us, + 256, + crate::nvidia_launch::MEASURED_T4_HPS, + PANEL_BUDGET_SECONDS, + 4, + NONCE_BASE, + ) + .expect("a T4 at its measured rate must be plannable"); + assert!(plan.is_a_comparison() && plan.soak_can_settle(PANEL_BUDGET_SECONDS)); + assert!(soak_can_settle(plan.pass_seconds, PANEL_BUDGET_SECONDS)); + // The measured optimum, 256 x 256 x 64, has to survive planning: a tune + // that drops the answer for cost is not a tune. + assert!( + plan.usable.contains(&shape( + 256, + crate::nvidia_launch::MEASURED_T4_BEST_UNIT_SIZE + )), + "the T4's measured winner was planned out of its own search" + ); + println!( + "T4 end to end: {} coarse candidates, {} usable, {:.2}s a pass at 7.54 MH/s", + plan.candidates.len(), + plan.usable.len(), + plan.pass_seconds + ); + } + + /// The multiprocessor counts live in one place. + #[test] + fn every_nvidia_panel_card_has_a_multiprocessor_count() { + use crate::gpu_arch::{GpuVendor, PANEL_GPU_PRESETS, profile_vendor}; + let mut named = 0; + for (slug, profile, _) in PANEL_GPU_PRESETS { + if profile_vendor(profile) != GpuVendor::Nvidia { + assert!( + nvidia_sm_count(slug).is_none(), + "{slug} is not an NVIDIA card but has an SM count" + ); + continue; + } + let sms = nvidia_sm_count(slug) + .unwrap_or_else(|| panic!("{slug} is an NVIDIA card with no multiprocessor count")); + assert!((16..=256).contains(&sms), "{slug}: {sms} multiprocessors"); + named += 1; + } + assert_eq!(named, crate::nvidia_launch::NVIDIA_PANEL_SM_COUNTS.len()); + } + + /// The soak's arithmetic, from three directions that have to agree. + /// + /// `max_soak_pass_seconds` is what `plan_session` sizes the corpus against, + /// `soak_can_settle` is `soak_until_settled`'s loop written out, and the + /// closed form is the algebra. Nothing in the tuner is allowed to hold a + /// fourth opinion about how long a pass may take. + #[test] + fn the_longest_settleable_pass_is_one_number_and_three_things_agree_on_it() { + for budget in [30u64, 60, 90, 120, 300, 600, 1_800, 7_200] { + let limit = max_soak_pass_seconds(budget); + assert_eq!( + limit, + soak_cap_seconds(budget) / (soak_window_passes() as f64 - 1.0) + ); + assert!( + soak_can_settle(limit * 0.99, budget), + "budget {budget}: a pass just under {limit:.2}s must settle" + ); + assert!( + !soak_can_settle(limit * 1.01, budget), + "budget {budget}: a pass just over {limit:.2}s must not" + ); + // The floor never outlasts the cap, or a soak could not end. + assert!(soak_floor_seconds(budget) <= soak_cap_seconds(budget)); + } + // The panel's own budget, spelled out: 90 s cap, 22.5 s a pass. + assert_eq!(soak_cap_seconds(PANEL_BUDGET_SECONDS), 90.0); + assert_eq!(max_soak_pass_seconds(PANEL_BUDGET_SECONDS), 22.5); + // A larger budget really does buy a longer soak, up to the 900 s cap. + assert_eq!(soak_cap_seconds(1_000), 500.0); + assert_eq!(soak_cap_seconds(100_000), 900.0); + } + + /// The plan is checked against the clock before a sweep starts, not after. + /// + /// The failure the old tuner had no way to see was that a pass could not fit + /// the soak's window; it discovered that only after sweeping, and then told + /// the operator to raise `benchmark_seconds`, which does not shorten a pass. + /// `plan_session` cannot return a plan with that property at all. + #[test] + fn a_plan_that_could_not_settle_is_refused_before_anything_is_measured() { + // Every window in the table, every plausible rate, every budget an + // operator can set: a returned plan always fits the soak. + use crate::gpu_arch::PANEL_GPU_PRESETS; + for (slug, profile, vram) in PANEL_GPU_PRESETS { + for cu in [8u32, 32, 128] { + let (min_wg, max_wg, max_us) = panel_button_window(slug, profile, vram, cu); + for hps in [0.5e6, 2.0e6, 10.0e6, 28.8e6, 120.0e6, 500.0e6] { + for budget in [30u64, 90, 300, 1_800] { + let where_ = format!("{slug} {cu} CU at {:.1} MH/s, {budget}s", hps / 1e6); + match plan_session(min_wg, max_wg, max_us, 256, hps, budget, 4, NONCE_BASE) + { + Ok(plan) => { + assert!( + soak_can_settle(plan.pass_seconds, budget), + "{where_}: planned a {:.1}s pass, which cannot settle", + plan.pass_seconds + ); + assert!( + plan.is_a_comparison(), + "{where_}: one shape is not a tune" + ); + assert!( + plan.corpus.segments >= 1 && plan.corpus.headers >= 1, + "{where_}: empty corpus" + ); + assert!( + plan.corpus.headers <= plan.corpus.segments, + "{where_}: {} headers over {} segments, so some header is \ + never hashed", + plan.corpus.headers, + plan.corpus.segments + ); + assert!( + plan.corpus.batches(plan.candidates[0]).is_ok(), + "{where_}: the corpus does not fit the nonce space" + ); + } + // A refusal is allowed, but it has to name what the + // operator can do about it. + Err(error) => assert!( + error.contains("work_groups") + || error.contains("benchmark_seconds"), + "{where_}: refused with no remedy: {error}" + ), + } + } + } + } + } + } + + /// What the tuner offers instead of dropping shapes silently: the budget + /// that would have kept them, and it has to be true. + #[test] + fn the_budget_the_tuner_names_really_does_buy_back_the_dropped_shapes() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + let mut checked = 0usize; + for (slug, profile, vram) in PANEL_GPU_PRESETS { + let (min_wg, max_wg, max_us) = panel_button_window(slug, profile, vram, 128); + for hps in [10.0e6, 28.8e6, 60.0e6] { + let plan = plan_session( + min_wg, + max_wg, + max_us, + 256, + hps, + PANEL_BUDGET_SECONDS, + 4, + NONCE_BASE, + ) + .unwrap(); + let Some(bigger) = plan.budget_for_every_shape else { + continue; + }; + if plan.off_corpus.is_empty() { + continue; + } + let richer = + plan_session(min_wg, max_wg, max_us, 256, hps, bigger, 4, NONCE_BASE).unwrap(); + assert!( + richer.off_corpus.is_empty(), + "{slug} at {:.1} MH/s: the tuner offered benchmark_seconds = {bigger}, and at \ + that budget it still drops {} shape(s)", + hps / 1e6, + richer.off_corpus.len() + ); + assert!( + richer.usable.len() > plan.usable.len(), + "{slug}: the larger budget bought nothing" + ); + assert!( + soak_can_settle(richer.pass_seconds, bigger), + "{slug}: the budget the tuner named cannot settle" + ); + checked += 1; + } + } + assert!(checked >= 10, "only {checked} cases exercised the offer"); + } + + /// The card the tuner was validated on keeps the tune it was validated with. + /// + /// This change exists to make every OTHER card work; if it moved the RX 9070 + /// XT's search space, the one result anyone has measured would no longer be + /// reachable and the fix would have cost more than it bought. + #[test] + fn the_validated_card_still_sweeps_its_whole_grid() { + let (min_wg, max_wg, max_us) = panel_button_window("rx9070xt", "amd_balanced", 16, 32); + assert_eq!((min_wg, max_wg, max_us), (32, 64, 192)); + + let universe = candidate_universe(min_wg, max_wg, max_us, 256); + let coarse = coarse_candidates(min_wg, max_wg, max_us, 256); + for hps in [10.0e6, 20.0e6, MEASURED_9070XT_HPS, 50.0e6] { + let plan = plan_session( + min_wg, + max_wg, + max_us, + 256, + hps, + PANEL_BUDGET_SECONDS, + 4, + NONCE_BASE, + ) + .unwrap(); + assert_eq!( + plan.usable.len(), + universe.len(), + "at {:.1} MH/s the 9070 XT lost grid points: {:?} {:?}", + hps / 1e6, + plan.over_ceiling, + plan.off_corpus + ); + assert_eq!(plan.candidates.len(), coarse.len()); + assert!(plan.over_ceiling.is_empty() && plan.off_corpus.is_empty()); + // 64x256x192, the measured winner, is still in the search. + assert!(plan.usable.contains(&Shape { + work_groups: 64, + local_size: 256, + unit_size: 192, + })); + } + + // And the corpus it gets at its measured rate: the same 18 874 368-nonce + // segment as before, four of them, a 2.6 s pass. + let plan = plan_session( + min_wg, + max_wg, + max_us, + 256, + MEASURED_9070XT_HPS, + PANEL_BUDGET_SECONDS, + 4, + NONCE_BASE, + ) + .unwrap(); + assert_eq!(plan.corpus.segment_nonces, 18_874_368); + assert_eq!(plan.corpus.segments, 4); + assert_eq!(plan.corpus.headers, 4); + assert!( + (plan.pass_seconds - 2.62).abs() < 0.05, + "pass {:.2}s", + plan.pass_seconds + ); + } + + /// A shape whose batch cannot meet the p95 ceiling is dropped before it is + /// measured, because `score` was always going to refuse it. + #[test] + fn a_shape_the_scorer_would_refuse_is_never_measured_in_the_first_place() { + // A window whose largest batch is 100 663 296 nonces, on a card doing + // 20 MH/s: that batch is 5.0 s, well over the 1.5 s ceiling. + let plan = plan_session(256, 3072, 128, 256, 20.0e6, 90, 4, NONCE_BASE).unwrap(); + let ceiling_nonces = (P95_BATCH_CEILING_MS / 1000.0) * LATENCY_HEADROOM * 20.0e6; + assert!(!plan.over_ceiling.is_empty(), "nothing was pruned"); + for shape in &plan.over_ceiling { + assert!(shape.nonces() as f64 > ceiling_nonces); + // And it really would have been refused: even at the generous + // headroom rate, its p95 cannot come in under the ceiling. + let best_case_ms = shape.nonces() as f64 / (20.0e6 * LATENCY_HEADROOM) * 1000.0; + assert!( + best_case_ms > P95_BATCH_CEILING_MS, + "{shape:?} would have taken {best_case_ms:.0} ms, inside the ceiling" + ); + } + for shape in plan.usable.iter().chain(plan.candidates.iter()) { + assert!( + shape.nonces() as f64 <= ceiling_nonces, + "{shape:?} survived the prune" + ); + } + // The prune can never empty the set: the smallest shape is kept whatever + // the rate, so a very slow card gets a refusal with a remedy and not a + // panic on an empty grid. + let crawling = plan_session(256, 3072, 128, 256, 1.0e3, 90, 4, NONCE_BASE); + match crawling { + Ok(plan) => assert!(!plan.candidates.is_empty()), + Err(error) => assert!( + error.contains("work_groups") || error.contains("benchmark_seconds"), + "{error}" + ), + } + } + + /// On a card with no board-power sensor, all three efficiency modes rank + /// candidates in exactly the same order. + /// + /// `Sampler::start` only reports `measures_power` when the reading comes + /// from `GpuTempSensorSource::AmdDriver`; `read_board_power_w` in + /// `efficiency.rs` returns `None` for every `Command` source (nvidia-smi) + /// and `detect_gpu_temp_sensor` returns `None` outright for Intel and + /// Unknown. So on every NVIDIA and Intel card `ScoreInput::gpu_watts` is the + /// same configured constant for every candidate, and both + /// `hashes_per_joule` and `net_eur_per_day` become affine in `valid_hps`. + /// + /// The tuner says so in its log ("optimises ... on estimated watts") but the + /// panel does not, and an operator who chose Eco gets Max. + #[test] + fn without_a_power_sensor_eco_and_profit_are_max_by_another_name() { + let econ = Economics { + power_cost_kwh: 0.30, + hac_price: 12.0, + hac_per_hps_day: Some(4.0e-9), + cpu_watts: 40.0, + }; + // Every mode resolves to a real objective, so nothing below is a + // fallback to throughput for a missing price. + assert_eq!( + resolve_objective(EfficiencyMode::Eco, &econ).0, + Objective::HashesPerJoule + ); + assert_eq!( + resolve_objective(EfficiencyMode::Profit, &econ).0, + Objective::NetIncome + ); + assert_eq!( + resolve_objective(EfficiencyMode::Max, &econ).0, + Objective::ValidHashrate + ); + + // The estimated-watts path: one number from [gpu] gpu_profile, reused + // for every candidate because no sensor contradicts it. + const ESTIMATED_WATTS: f64 = 260.0; + let candidates: Vec = [12.0e6, 19.0e6, 24.5e6, 28.8e6, 21.0e6] + .into_iter() + .map(|hashrate| ScoreInput { + hashrate, + mean_batch_seconds: 0.12, + p95_batch_ms: 140.0, + gpu_watts: ESTIMATED_WATTS, + }) + .collect(); + + let order = |objective: Objective| -> Vec { + let mut index: Vec = (0..candidates.len()).collect(); + index.sort_by(|a, b| { + score(&candidates[*b], objective, &econ, P95_BATCH_CEILING_MS) + .unwrap() + .total_cmp( + &score(&candidates[*a], objective, &econ, P95_BATCH_CEILING_MS).unwrap(), + ) + }); + index + }; + + let by_rate = order(Objective::ValidHashrate); + assert_eq!(order(Objective::HashesPerJoule), by_rate, "Eco is not Eco"); + assert_eq!(order(Objective::NetIncome), by_rate, "Profit is not Profit"); + + // And it is only the shared constant that does it: give two candidates + // real, different draws and the orders separate again, which is what an + // AMD card on Windows gets and nothing else does. + let thirsty = ScoreInput { + gpu_watts: ESTIMATED_WATTS * 1.9, + ..candidates[3] + }; + let frugal = candidates[2]; + assert!( + score( + &thirsty, + Objective::ValidHashrate, + &econ, + P95_BATCH_CEILING_MS + ) > score( + &frugal, + Objective::ValidHashrate, + &econ, + P95_BATCH_CEILING_MS + ) + ); + assert!( + score( + &thirsty, + Objective::HashesPerJoule, + &econ, + P95_BATCH_CEILING_MS + ) < score( + &frugal, + Objective::HashesPerJoule, + &econ, + P95_BATCH_CEILING_MS + ), + "with real watts Eco must be able to prefer the slower shape" + ); + } + + // ----------------------------------------------------------------------- + // The T4 measurement, and what the tuner has to be able to say about it + // ----------------------------------------------------------------------- + + /// Every launch shape measured on a real Tesla T4 is on the tuner's grid, + /// and the tuner can reach the one that won. + /// + /// Measured on a Colab T4 at repeat 16 over a fixed corpus, steady state + /// after 40 warm-up batches, flat to 0.57%: + /// + /// work_groups 256, local_size 256, unit_size 64 -> 7.54 MH/s + /// work_groups 256, local_size 256, unit_size 96 -> 7.19 MH/s + /// work_groups 256, local_size 256, unit_size 128 -> 7.06 MH/s + /// + /// That ordering is the REVERSE of the RX 9070 XT's, where unit_size 192 + /// beats 64 by about 9%. Two cards, opposite optima, which is exactly why + /// the grid may not be a fixed table. What this pins is that a T4-shaped + /// device window puts all three of those points in the universe, and that + /// the winner is reachable from a point the coarse sweep actually visits. + #[test] + fn the_shapes_measured_on_a_real_t4_are_on_the_grid_and_the_winner_is_reachable() { + // A T4 as the CUDA probe describes it: 40 multiprocessors, one resident + // block of 256 threads each, so 40 is the smallest launch that fills it. + let min_wg = 40; + // Roughly what 55% of ~15 GiB free holds at unit_size 128, 36 bytes a + // nonce. The exact figure is the CUDA crate's to compute; what matters + // here is that the window is wide. + let max_wg = 7_000; + let max_unit = 128; + let universe = candidate_universe(min_wg, max_wg, max_unit, 256); + + for unit_size in [64u32, 96, 128] { + assert!( + universe.contains(&shape(256, unit_size)), + "256x256x{unit_size} was measured on a T4 and must be a shape the tuner can try" + ); + } + + // The coarse sweep is half the grid, so 256 work groups is a refinement + // point rather than a coarse one. It must still be reachable: a tune + // that could never propose the shape that won on the one NVIDIA card + // anyone has measured would be a tuner in name only. + let coarse = coarse_candidates(min_wg, max_wg, max_unit, 256); + let reachable = coarse.iter().any(|base| { + refine_candidates(*base, min_wg, max_wg, max_unit).contains(&shape(256, base.unit_size)) + }); + assert!( + reachable, + "no coarse candidate refines to 256 work groups; the T4 optimum is unreachable" + ); + + // The small end of the unit_size axis is where a power-capped card's + // optimum lives, so the grid must not start above it. + assert_eq!( + unit_size_grid(max_unit).first().copied(), + Some(32), + "a power-capped card wants a SMALL unit_size; the axis has to start below 64" + ); + } + + #[test] + fn a_win_smaller_than_the_measurement_noise_is_reported_as_unresolved() { + // Two shapes whose medians differ by well under a percent, each of which + // wandered by about 3% across its own repeats. Nothing was demonstrated + // here, and the tuner has to say so rather than print a winner and stop. + let noisy = vec![ + FinalistRuns { + shape: shape(256, 64), + scores: vec![7.40e6, 7.54e6, 7.62e6], + }, + FinalistRuns { + shape: shape(256, 96), + scores: vec![7.35e6, 7.51e6, 7.58e6], + }, + ]; + let resolution = comparison_resolution_pct(&noisy); + let margin = winning_margin_pct(&noisy).expect("two finalists have a margin"); + assert!(resolution > 2.0, "each shape spanned about 3% of itself"); + assert!(margin < 1.0); + assert!(margin < resolution); + let note = resolution_note(&noisy, "cuda"); + assert!(note.contains("does NOT clear it"), "{note}"); + + // The same two medians, measured on a card that reproduced to a tenth of + // a percent: now the margin is a result. + let steady = vec![ + FinalistRuns { + shape: shape(256, 64), + scores: vec![7.539e6, 7.540e6, 7.541e6], + }, + FinalistRuns { + shape: shape(256, 96), + scores: vec![7.189e6, 7.190e6, 7.191e6], + }, + ]; + let note = resolution_note(&steady, "cuda"); + assert!(comparison_resolution_pct(&steady) < 0.1); + assert!(winning_margin_pct(&steady).unwrap() > 4.0); + assert!(note.contains("which clears it"), "{note}"); + } + + #[test] + fn a_single_finalist_never_claims_a_comparison() { + // One shape survived to the final round. There is a number, but there is + // no comparison, and "resolved" would be a claim about nothing. + let alone = vec![FinalistRuns { + shape: shape(256, 64), + scores: vec![7.54e6, 7.55e6, 7.53e6], + }]; + assert_eq!(winning_margin_pct(&alone), None); + let note = resolution_note(&alone, "cuda"); + assert!(note.contains("nothing was compared"), "{note}"); + + // And a finalist with a single pass contributes no noise estimate: one + // measurement cannot disagree with itself, and counting it as 0% would + // make every margin look resolved. + let one_pass = vec![ + FinalistRuns { + shape: shape(256, 64), + scores: vec![7.54e6], + }, + FinalistRuns { + shape: shape(256, 96), + scores: vec![7.19e6, 7.60e6, 7.30e6], + }, + ]; + assert!( + comparison_resolution_pct(&one_pass) > 5.0, + "the resolution must come from the shape that actually repeated" + ); + } + + #[test] + fn the_report_says_what_a_number_from_another_run_is_worth_and_says_more_on_cuda() { + let finalists = vec![ + FinalistRuns { + shape: shape(256, 64), + scores: vec![7.539e6, 7.540e6, 7.541e6], + }, + FinalistRuns { + shape: shape(256, 96), + scores: vec![7.189e6, 7.190e6, 7.191e6], + }, + ]; + // Both backends carry the between-process bound, because it is a + // property of the card settling into a clock state for the life of a + // process and not of the GPU API. + for backend in ["opencl", "cuda"] { + let note = resolution_note(&finalists, backend); + assert!( + note.contains(&format!( + "{:.1}%", + crate::x16rs_gate::BETWEEN_PROCESS_SPREAD_PCT + )), + "{backend}: {note}" + ); + } + // Only CUDA carries the extra claim, and it is the true one: nvcc + // compiles the kernel into the binary, so two CUDA KERNELS are two + // binaries and `x16rs_gate ab`'s in-process alternation is not available + // for them. Launch shapes, which is what a tune compares, are alternated + // in-process on both backends and resolve far finer. + let cuda = resolution_note(&finalists, "cuda"); + let opencl = resolution_note(&finalists, "opencl"); + assert!( + cuda.contains("nvcc compiles the kernel into the binary"), + "{cuda}" + ); + assert!(!opencl.contains("nvcc"), "{opencl}"); + assert!(cuda.len() > opencl.len()); + } + + /// One device allocation may serve several candidates, and it is sized from + /// the ones it will really launch. + /// + /// This is the rule a backend that bakes unit_size into its buffers depends + /// on. Getting it wrong in the tempting direction - allocate for the largest + /// shape in the plan - asks the card for memory no launch would ever use. + #[test] + fn a_shared_allocation_is_sized_from_the_shapes_that_share_its_unit_size() { + let plan = vec![ + shape(3072, 32), + shape(1536, 32), + shape(512, 64), + shape(256, 128), + shape(128, 128), + ]; + + // Work groups come from the plan, so one device at unit_size 32 serves + // both 1536 and 3072, and one at 128 serves both 128 and 256. + assert_eq!(shared_allocation_work_groups(&plan, shape(1536, 32)), 3072); + assert_eq!(shared_allocation_work_groups(&plan, shape(128, 128)), 256); + assert_eq!(shared_allocation_work_groups(&plan, shape(512, 64)), 512); + + // The unit_size filter is the whole point. The largest work-group count + // in this plan is 3072 and the largest unit_size is 128, but no planned + // shape is 3072x256x128: allocating one would be 100 M nonces and 3.6 GB + // of device memory for a launch that cannot happen. + let naive_nonces = 3072u64 * 256 * 128; + let real = shared_allocation_work_groups(&plan, shape(256, 128)) as u64 * 256 * 128; + assert_eq!(real, 256 * 256 * 128); + assert!( + real * 10 < naive_nonces, + "sizing by the plan's largest shape would over-allocate by more than 10x" + ); + + // A shape the plan never mentioned still gets an allocation it can use, + // which is what the probe needs before a plan exists. + assert_eq!(shared_allocation_work_groups(&[], shape(48, 32)), 48); + assert_eq!(shared_allocation_work_groups(&plan, shape(4096, 32)), 4096); + + // local_size is part of the identity too: a device allocated for one + // block size cannot serve another. + let other_block = Shape { + work_groups: 4096, + local_size: 64, + unit_size: 32, + }; + assert_eq!( + shared_allocation_work_groups(&[other_block], shape(256, 32)), + 256 + ); + } + + /// A T4-shaped device window plans a real comparison at the shipped budget. + /// + /// This is a regression test for a failure that had nothing to do with the + /// card. A Tesla T4 has 40 multiprocessors and this kernel gets one resident + /// block on each, so the work-group axis starts at 48 - the first dyadic + /// grid point at or above 40. Taking every other point from index 0 then + /// makes EVERY coarse candidate a 3-multiple; the shared corpus segment is a + /// common multiple of the candidates' batches, so one 3-multiple triples it; + /// `plan_corpus` drops the shapes that will not fit the budget and drops the + /// 3-multiples first; and the coarse set comes back empty. The operator's + /// tune then ends with "only 0 launch shapes survived planning" on a card + /// with nothing wrong with it. + /// + /// The floor is not negotiable - below one block per multiprocessor the card + /// is idle by construction - so `coarse_axis` chooses the family instead. + #[test] + fn a_t4_shaped_device_window_plans_a_comparison_at_the_shipped_budget() { + // 40 multiprocessors, one resident block each; ~7400 work groups is what + // 55% of a T4's free memory holds at unit_size 128, 36 bytes a nonce. + let (min_wg, max_wg, max_unit) = (40, 7_387, 128); + + // The probe runs the SMALLEST shape, which under-feeds this kernel, so it + // reads below the 7.5 MH/s the card sustains at a good shape. Planned + // across the range a probe could plausibly return. + for probe_hps in [3.0e6f64, 5.0e6, 7.5e6] { + let plan = plan_session(min_wg, max_wg, max_unit, 256, probe_hps, 90, 4, NONCE_BASE) + .unwrap_or_else(|error| { + panic!( + "a T4 at {:.1} MH/s must be plannable: {error}", + probe_hps / 1e6 + ) + }); + assert!( + plan.is_a_comparison(), + "at {:.1} MH/s the coarse set was {} shapes", + probe_hps / 1e6, + plan.candidates.len() + ); + // Every planned candidate has to tile the corpus it was planned for, + // or the shapes are not being measured on the same work. + for shape in &plan.candidates { + assert!( + plan.corpus.fits(*shape), + "{shape} cannot tile the {}-nonce segment it was planned onto", + plan.corpus.segment_nonces + ); + coverage_matches(&plan.corpus, plan.candidates[0], *shape).unwrap(); + } + assert!(plan.soak_can_settle(90)); + // The T4's measured optimum sits at 256 work groups, and it must be + // a shape the sweep really measures rather than one the corpus + // dropped for cost. + assert!( + plan.candidates.iter().any(|shape| shape.work_groups == 256), + "at {:.1} MH/s no candidate had the 256 work groups the T4 was measured at", + probe_hps / 1e6 + ); + } + + // The family chosen for a grid that starts at 48 is the one the shared + // corpus can afford. The one exception is the axis's top end, which + // `coarse_axis` always appends whichever family it belongs to, because + // the largest launch the device permits is worth visiting on its own. + let coarse = coarse_candidates(min_wg, max_wg, max_unit, 256); + let top = work_group_grid(min_wg, max_wg).last().copied().unwrap(); + assert!( + coarse + .iter() + .all(|shape| shape.work_groups.is_power_of_two() || shape.work_groups == top), + "the coarse work-group axis must be the family the shared corpus can afford, \ + plus the grid's top end" + ); + + // A grid that starts on a power of two is untouched: this is the RX 9070 + // XT's window, and its coarse axis is what it always was. + let amd = coarse_candidates(32, 64, 192, 256); + let amd_axis: Vec = { + let mut wg: Vec = amd.iter().map(|shape| shape.work_groups).collect(); + wg.dedup(); + wg + }; + assert_eq!(amd_axis, vec![32, 64]); + } +} diff --git a/app/src/bench_mainnet_repeat16.rs b/app/src/bench_mainnet_repeat16.rs index 15c5c88d..eddf2341 100644 --- a/app/src/bench_mainnet_repeat16.rs +++ b/app/src/bench_mainnet_repeat16.rs @@ -31,20 +31,20 @@ use std::time::{Duration, Instant}; /// A height whose block_hash_repeat is the mainnet maximum of 16. /// 800_000 / 50_000 + 1 = 17 -> clamped to 16 by x16rs::block_hash_repeat. -#[cfg(any(feature = "ocl", test))] +#[cfg(any(feature = "ocl", feature = "cuda", test))] pub const MAINNET_REPEAT16_HEIGHT: u64 = 800_000; /// A height whose block_hash_repeat is 1 (matches the stock auto-tune). -#[cfg(any(feature = "ocl", test))] +#[cfg(any(feature = "ocl", feature = "cuda", test))] pub const REPEAT1_HEIGHT: u64 = 1; -#[cfg(any(feature = "ocl", test))] +#[cfg(feature = "ocl")] const WARMUP_BATCHES: u32 = 3; -#[cfg(any(feature = "ocl", test))] +#[cfg(feature = "ocl")] const MIN_VALID_SAMPLES: u32 = 5; /// Fully-instrumented result of one measurement run at a fixed height/repeat. -#[cfg(any(feature = "ocl", test))] +#[cfg(any(feature = "ocl", feature = "cuda", test))] #[derive(Clone, Debug)] pub struct RepeatBenchReport { pub height: u64, @@ -61,7 +61,7 @@ pub struct RepeatBenchReport { pub nonces_per_sec: f64, } -#[cfg(any(feature = "ocl", test))] +#[cfg(any(feature = "ocl", feature = "cuda", test))] impl RepeatBenchReport { /// Human-readable, copy-pasteable proof block for a community post. pub fn render(&self) -> String { @@ -92,7 +92,7 @@ impl RepeatBenchReport { /// Format a nonces/sec figure as H/s, kH/s or MH/s (self-contained; does not /// depend on basis::difficulty so this module stays drop-in). -#[cfg(any(feature = "ocl", test))] +#[cfg(any(feature = "ocl", feature = "cuda", test))] pub fn fmt_rate(hps: f64) -> String { if hps >= 1_000_000.0 { format!("{:.2} MH/s", hps / 1_000_000.0) @@ -105,7 +105,7 @@ pub fn fmt_rate(hps: f64) -> String { /// Re-hash the GPU's returned best nonce on the CPU and byte-compare. /// Returns Ok(()) only when the GPU output is provably correct for `height`. -#[cfg(any(feature = "ocl", test))] +#[cfg(any(feature = "ocl", feature = "cuda", test))] pub fn cpu_verify_batch( height: u64, block_intro: &[u8], diff --git a/app/src/block_mining_runtime.rs b/app/src/block_mining_runtime.rs index 8a24cd6b..10b5d0a1 100644 --- a/app/src/block_mining_runtime.rs +++ b/app/src/block_mining_runtime.rs @@ -84,6 +84,32 @@ const HASHRATE_EWMA_NEW_WEIGHT: f64 = 0.25; const TARGET_BLOCK_TIME: f64 = 300.0; const ONEDAY_BLOCK_NUM: f64 = 288.0; +/// HAC a single hash per second earns in a day, at the difficulty `target_hash` +/// encodes and the block reward paid at `height`. +/// +/// This is the mining line's own arithmetic, factored out so the auto-tuner can +/// price a hash with it instead of inventing a second formula that would drift +/// from the one the operator sees on screen: `hac_per_day` on that line is +/// exactly `hashrate * this`. +/// +/// It is only meaningful for a NETWORK target. A pool serves a share target, +/// which is far weaker, and feeding one in here would value a hash at hundreds +/// of times what it is worth; the caller is responsible for not doing that. +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub(crate) fn hac_per_day_per_hashrate(height: u64, target_hash: &[u8]) -> Option { + // The array length is inferred from `hash_to_rates`, exactly as the mining + // line does it, so a change to the hash width cannot leave this behind. + let Ok(target) = target_hash.to_vec().try_into() else { + return None; + }; + let target_rates = hash_to_rates(&target, TARGET_BLOCK_TIME); + if !target_rates.is_finite() || target_rates <= 0.0 { + return None; + } + let value = ONEDAY_BLOCK_NUM * block_reward_number(height) as f64 / target_rates; + value.is_finite().then_some(value) +} + static MINING_BLOCK_HEIGHT: AtomicU64 = AtomicU64::new(0); /// Set while the upstream (pool bridge or fullnode) tells us the work it is /// serving is no longer being refreshed. The installed template can no longer win @@ -106,6 +132,40 @@ static MINER_CLOCK: LazyLock = LazyLock::new(Instant::now); static SHARE_HITS_DROPPED: AtomicU64 = AtomicU64::new(0); /// `MINER_CLOCK` millis at the last undersampling line (0 = never). static SHARE_OVERFLOW_LAST_LOG_MS: AtomicU64 = AtomicU64::new(0); +/// How long a profitability pause holds before the rig is allowed to measure +/// again. One minute: long enough that a rig which really is unprofitable spends +/// almost nothing, short enough that a rig paused on a momentary reading, or on a +/// HAC price that has since recovered, is not finished for the day. +const PROFIT_PAUSE_RECHECK_MS: u64 = 60_000; +/// Result-thread clock millis at which THIS worker's profitability pause began, +/// or 0 when it did not set one. +/// +/// The pause stops the very workers whose results are the only thing that clears +/// it, so without a way back it is a one-way latch: `run_block_mining_item` +/// returns early while gated, the drain then sees nothing, and the clear sits +/// past the empty-drain return. A rig paused on one bad reading never mined +/// again for the life of the process, and the operator was told only that its +/// power cost exceeded its revenue. +/// +/// Zero also means "this worker did not pause the rig". `diaworker` shares the +/// flag, and the HACD side's pause is not this side's to lift on a timer. +static PROFIT_PAUSE_SINCE_MS: AtomicU64 = AtomicU64::new(0); +/// Result-thread clock millis at the last "this header cannot be priced" line +/// (0 = never). The drain runs about eight times a second, so an unsound header +/// would otherwise fill the console faster than an operator could read it. +static UNPRICEABLE_LAST_LOG_MS: AtomicU64 = AtomicU64::new(0); + +/// Is the "this header cannot be priced" line due? Records that it was said. +fn unpriceable_template_line_due(now_ms: u64) -> bool { + let last = UNPRICEABLE_LAST_LOG_MS.load(Relaxed); + // Said once at startup (last == 0), then at most once a minute. A clock that + // steps backwards reads as due rather than silencing a live fault. + if last != 0 && now_ms >= last && now_ms - last < PROFIT_PAUSE_RECHECK_MS { + return false; + } + UNPRICEABLE_LAST_LOG_MS.store(now_ms.max(1), Relaxed); + true +} #[derive(Clone)] pub(crate) enum MinerBackend { @@ -148,6 +208,16 @@ pub(crate) struct BlockMiningResult { pub coinbase_nonce: Vec, pub result_hash: Vec, pub target_hash: Vec, + /// Header `difficulty` of the template this result was mined against, which is + /// always the NETWORK difficulty whatever `target_hash` above happens to be. + /// Pooled, `target_hash` is the pool's SHARE target, easier than a block by + /// 2^share_bits, so it cannot say what a day of mining is worth. This can: a + /// block whose `difficulty` field is not the value the node recomputes is + /// rejected, so a pool cannot shrink it and still be paid for the block. + /// Carried on the result rather than read from the live template so the + /// hashrate, the height and the difficulty all come from ONE snapshot and can + /// never describe different jobs. + network_difficulty: u32, use_secs: f64, is_gpu: bool, } @@ -551,7 +621,7 @@ fn classify_submit_response(body: &str) -> Option<(SubmitVerdict, String)> { /// verdict from a transport failure, and that function returns `()`. fn submit_block_mining_success(cnf: &PoWorkConf, success: &BlockMiningResult) -> SubmitVerdict { let urlapi_success = format!( - "http://{}/submit/miner/success?height={}&block_nonce={}&coinbase_nonce={}&t={}{}", + "{}/submit/miner/success?height={}&block_nonce={}&coinbase_nonce={}&t={}{}", &cnf.rpcaddr, success.height, success.head_nonce, @@ -584,7 +654,8 @@ fn submit_block_mining_success(cnf: &PoWorkConf, success: &BlockMiningResult) -> _ => { wlogln!( "[submit] node rejected height {}: {}", - success.height, err + success.height, + err ); } } @@ -598,7 +669,9 @@ fn submit_block_mining_success(cnf: &PoWorkConf, success: &BlockMiningResult) -> let snippet: String = body.chars().take(120).collect(); wlogln!( "[submit] attempt {}/{} unrecognized response, retrying: {}", - attempt, MAX_SUBMIT_ATTEMPTS, snippet + attempt, + MAX_SUBMIT_ATTEMPTS, + snippet ); if attempt < MAX_SUBMIT_ATTEMPTS { std::thread::sleep(Duration::from_millis(500u64 * attempt as u64)); @@ -610,7 +683,8 @@ fn submit_block_mining_success(cnf: &PoWorkConf, success: &BlockMiningResult) -> last = format!("transport error: {e}"); wlogln!( "[submit] attempt {}/{} failed: {e}", - attempt, MAX_SUBMIT_ATTEMPTS + attempt, + MAX_SUBMIT_ATTEMPTS ); if attempt < MAX_SUBMIT_ATTEMPTS { std::thread::sleep(Duration::from_millis(500u64 * attempt as u64)); @@ -872,7 +946,9 @@ impl SubmitGate { if entry.state == TemplateSubmitState::Settled { wlogln!( "\n[Mining] height {} {}, suppressed {} redundant winners.", - key.0, entry.outcome, entry.suppressed + key.0, + entry.outcome, + entry.suppressed ); } else { // Never settled, so these were not provably dead: they were @@ -881,7 +957,9 @@ impl SubmitGate { // redundant. wlogln!( "\n[Mining] height {} {}, held back {} further winners.", - key.0, entry.outcome, entry.suppressed + key.0, + entry.outcome, + entry.suppressed ); } } @@ -1523,6 +1601,10 @@ fn run_block_mining_item( // never touches the GPU share path at all. let share_target = pool_share_target(_cnf, &stuff); let prevhash = stuff.block_intro.prevhash().to_vec(); + // The network difficulty of the header these batches hash, read from the same + // template snapshot as everything else here. Pooled, `stuff.target_hash` is + // the pool's share target and says nothing at all about the network. + let network_difficulty = stuff.block_intro.difficulty().uint(); let mut coinbase_tx = stuff.coinbase_tx.clone(); coinbase_tx.set_nonce(coinbase_nonce); let mut block_intro = stuff.block_intro.clone(); @@ -1610,6 +1692,7 @@ fn run_block_mining_item( coinbase_nonce: coinbase_nonce.to_vec(), result_hash: result_hash.to_vec(), target_hash: stuff.target_hash.to_vec(), + network_difficulty, use_secs, is_gpu: is_gpu_backend, }; @@ -1638,6 +1721,7 @@ fn run_block_mining_item( coinbase_nonce: coinbase_nonce.to_vec(), result_hash: share.hash.to_vec(), target_hash: stuff.target_hash.to_vec(), + network_difficulty, use_secs: 0.0, is_gpu: is_gpu_backend, }; @@ -1783,16 +1867,62 @@ fn deal_block_mining_results( } } if recv_count == 0 { + // A paused rig produces no results, so this is the ONLY branch it ever + // reaches again. Let the pause expire here, or it is permanent: the rig + // would need a restart even after the reason for it had gone. One fresh + // measurement window is all this buys; if the rig is still unprofitable + // the very next tick pauses it again, so the cost is seconds of power + // rather than a wrong answer that lasts all day. + let since = PROFIT_PAUSE_SINCE_MS.load(Relaxed); + if since != 0 + && now_ms.saturating_sub(since) >= PROFIT_PAUSE_RECHECK_MS + && cnf.runtime.paused_unprofitable.load(Relaxed) + { + PROFIT_PAUSE_SINCE_MS.store(0, Relaxed); + cnf.runtime.paused_unprofitable.store(false, Relaxed); + } return; } if hash_more_power(&most.result_hash, most_hash) { *most_hash = most.result_hash.clone(); } - let Ok(tarhx) = most.target_hash.clone().try_into() else { + if most.target_hash.len() != HASH_WIDTH { wlogerr!("[Mining] Ignoring result with invalid target hash length."); return; - }; - let target_rates = hash_to_rates(&tarhx, TARGET_BLOCK_TIME); + } + // A header claiming difficulty 0 is one no node will ever accept, so nothing + // below can say what a day of mining on it is worth. `set_pending_block_stuff` + // validates only the served `target_hash`; the intro's difficulty is a + // separate field it never inspects, so a pool really can install one of these. + // + // This does NOT return. Winners are queued for submission further down, and a + // found block is irreplaceable money: a template this miner cannot PRICE is + // still a template it may have just won on, and the node decides what it + // accepts. What is skipped is only the profitability decision, because + // pausing a rig over a header the pool got wrong would cost its operator + // their whole income for a fault that is not theirs, and reporting a revenue + // of zero would be inventing a figure rather than admitting there is none. + let revenue_is_knowable = most.network_difficulty != 0; + if !revenue_is_knowable && unpriceable_template_line_due(now_ms) { + wlogerr!( + "[Mining] The served block header claims difficulty 0, which no node accepts, so \ + this miner cannot say what its work is worth. HAC/day and the profitability pause \ + are switched off until a sound header arrives. Shares and blocks are still \ + submitted. Check the pool or node this miner connects to." + ); + } + // What a day of mining is worth is set by the NETWORK target, never by the + // target this result happened to be measured against. Pooled, that one is the + // pool's share target, 2^share_bits easier than a block, so dividing by it + // made `mnper` hit the 1.0 clamp below: every pooled rig read as 100% of the + // network, claimed a full block reward every block on screen and in the panel, + // and `pause_if_unprofitable` could never fire however much power it burned. + // The header the miner is hashing carries the real difficulty, and a block + // whose `difficulty` field is not the value the node recomputes is rejected, + // so it is the one number a pool cannot quietly shrink. What this still + // cannot know is the pool's fee and its own luck, so it is gross + // network-share revenue and not the payout. + let target_rates = u32_to_rates(most.network_difficulty, TARGET_BLOCK_TIME); let rates = rate_tracker.totals(now_ms); let gpu_hashrate = rates.gpu_hps; let cpu_hashrate = rates.cpu_hps; @@ -1811,12 +1941,35 @@ fn deal_block_mining_results( let active_cpu = cnf.runtime.active_cpu_assist.load(Relaxed); cnf.runtime .maybe_adjust_supervene(&cnf.efficiency, gpu_nonce_space, cpu_nonce_space); - if should_pause_for_profit(&cnf.efficiency, hac1day, &cnf.gpu_profile, active_cpu) { + // One reading, used for the pause decision and the line that reports it, so + // the operator can never be shown a cost that disagrees with the cost the + // rig was paused on. + let measured_gpu_w = cnf.runtime.gpu_board_power_w(); + if !revenue_is_knowable { + // No decision either way, and the previous one is left standing. The rig + // keeps doing whatever it was doing until a header it can price arrives. + } else if should_pause_for_profit( + &cnf.efficiency, + hac1day, + &cnf.gpu_profile, + active_cpu, + measured_gpu_w, + ) { cnf.runtime.paused_unprofitable.store(true, Relaxed); + // Stamped so the empty-drain branch above can let it expire. Never 0: + // that value means this worker did not pause the rig. + PROFIT_PAUSE_SINCE_MS.store(now_ms.max(1), Relaxed); wlogln!( - "\n[efficiency] Mining paused: estimated cost exceeds HAC revenue. Set pause_if_unprofitable=false or lower power draw." + "\n[efficiency] Mining paused for up to {}s: {} cost exceeds HAC revenue. It resumes by itself to measure again. Set pause_if_unprofitable=false or lower power draw.", + PROFIT_PAUSE_RECHECK_MS / 1_000, + if measured_gpu_w.is_some() { + "measured" + } else { + "estimated" + } ); } else { + PROFIT_PAUSE_SINCE_MS.store(0, Relaxed); cnf.runtime.paused_unprofitable.store(false, Relaxed); } let eff_line = format_efficiency_line( @@ -1826,6 +1979,7 @@ fn deal_block_mining_results( &cnf.efficiency, &cnf.gpu_profile, active_cpu, + measured_gpu_w, ); flush!( "{} {} | {} | best {}. \r", @@ -1866,13 +2020,6 @@ fn deal_block_mining_results( } queue_block_mining_success(cnf, submit_tx, gate, w); } - } else if cnf.debug == 1 { - // Debug mode exercises the submit path even without a genuine winner. It - // goes through the same gate, so it submits once per template instead of - // once per drain tick. - if admit_for_submit(gate, &most) { - queue_block_mining_success(cnf, submit_tx, gate, &most); - } } may_print_turn_to_nex_block_mining(deal_hei, Some(most_hash)); } @@ -2042,6 +2189,260 @@ mod tests { assert!(!result_is_orphaned(&res, None)); } + #[test] + fn a_pooled_rig_prices_its_day_on_the_network_target_not_the_share_target() { + let _guard = mining_state_guard(); + set_pending_block_stuff(500, pending_template_json(500, 0x11, 0xb1)).unwrap(); + + // Mainnet-shaped network difficulty: target 2^208, i.e. ~2^48 hashes per + // block, ~9.4e11 H/s at the 300 s block time. + const NETWORK_DIFFICULTY: u32 = 0xD080_0000; + // What a pool actually serves in `target_hash`: ~17 hashes to a share. + // Any real rig saturates it, and that saturation is the whole defect. + let share_target = vec![0x0fu8; HASH_WIDTH]; + + let mut cnf = PoWorkConf::test_defaults("127.0.0.1:1".to_string(), 1, 16); + cnf.pool_worker = "1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(); + cnf.efficiency.pause_if_unprofitable = true; + cnf.efficiency.hac_price = 1.0; + cnf.efficiency.power_cost_kwh = 0.15; + cnf.efficiency.gpu_watts = 100.0; + // No CPU term, so the whole power bill is at most 0.36 EUR a day. + cnf.efficiency.cpu_watts_per_thread = 0.0; + + // 1 MH/s against a 9.4e11 H/s network earns ~3e-4 HAC a day, far less than + // the electricity: the pause MUST fire. Priced off the share target the + // same rig reads as a full 288 HAC a day and never pauses. + let mut small = BlockMiningResult::default(); + small.height = 500; + small.prevhash = vec![0x11u8; HASH_WIDTH]; + small.nonce_space = 1_000_000; + small.use_secs = 1.0; + small.target_hash = share_target.clone(); + // Above the share target, so this is a statistics-only result and nothing + // is queued for submission. + small.result_hash = vec![0x7fu8; HASH_WIDTH]; + small.network_difficulty = NETWORK_DIFFICULTY; + + let (small_tx, mut small_rx) = mpsc::sync_channel::>(4); + small_tx.send(Arc::new(small)).unwrap(); + let (submit_tx, _submit_rx) = mpsc::sync_channel::>(4); + let mut most_hash = vec![255u8; HASH_WIDTH]; + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut small_rx, + 1, + &mut HashrateTracker::default(), + 1_000, + &submit_tx, + &test_gate(), + ); + assert!( + cnf.runtime.paused_unprofitable.load(Relaxed), + "a 1 MH/s rig on a 9.4e11 H/s network cannot pay for its own power; \ + pricing the day off the pool's share target hides that completely" + ); + + // Same pool, same share target, a rig that really is ~10% of the network. + // The revenue is real, so mining must NOT be paused: this is what stops + // the fix degenerating into "pooled rigs always pause". + let mut big = BlockMiningResult::default(); + big.height = 500; + big.prevhash = vec![0x11u8; HASH_WIDTH]; + big.nonce_space = 100_000_000; + big.use_secs = 0.001; + big.target_hash = share_target; + big.result_hash = vec![0x7fu8; HASH_WIDTH]; + big.network_difficulty = NETWORK_DIFFICULTY; + + let (big_tx, mut big_rx) = mpsc::sync_channel::>(4); + big_tx.send(Arc::new(big)).unwrap(); + let mut most_hash = vec![255u8; HASH_WIDTH]; + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut big_rx, + 1, + &mut HashrateTracker::default(), + 2_000, + &submit_tx, + &test_gate(), + ); + assert!( + !cnf.runtime.paused_unprofitable.load(Relaxed), + "a rig earning tens of HAC a day must keep mining" + ); + } + + #[test] + fn a_header_this_miner_cannot_price_still_submits_its_winner() { + let _guard = mining_state_guard(); + set_pending_block_stuff(500, pending_template_json(500, 0x11, 0xb1)).unwrap(); + + // Difficulty 0: no node accepts such a block, so nothing can say what a + // day of work on it is worth. `set_pending_block_stuff` checks only the + // served `target_hash`, never the intro's difficulty, so a pool really can + // install one of these. + let mut cnf = PoWorkConf::test_defaults("127.0.0.1:1".to_string(), 1, 16); + cnf.efficiency.pause_if_unprofitable = true; + cnf.efficiency.hac_price = 1.0; + cnf.efficiency.power_cost_kwh = 0.15; + cnf.efficiency.gpu_watts = 100.0; + cnf.efficiency.cpu_watts_per_thread = 0.0; + + let mut win = BlockMiningResult::default(); + win.height = 500; + win.prevhash = vec![0x11u8; HASH_WIDTH]; + win.nonce_space = 1; + win.use_secs = 0.5; + win.head_nonce = 77; + win.coinbase_nonce = vec![0x05; HASH_WIDTH]; + win.target_hash = vec![0x0f; HASH_WIDTH]; + win.result_hash = vec![0x01; HASH_WIDTH]; // under target: a real winner + win.network_difficulty = 0; + + let (res_tx, mut res_rx) = mpsc::sync_channel::>(4); + res_tx.send(Arc::new(win)).unwrap(); + let (submit_tx, submit_rx) = mpsc::sync_channel::>(4); + let mut most_hash = vec![255u8; HASH_WIDTH]; + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut res_rx, + 1, + &mut HashrateTracker::default(), + 1, + &submit_tx, + &test_gate(), + ); + + // A block is irreplaceable and the NODE decides what it accepts. Refusing + // to price a template must never become refusing to submit a win on it. + let submitted = submit_rx + .try_recv() + .expect("a winner must be submitted even on a header this miner cannot price"); + assert_eq!(submitted.head_nonce, 77); + // And no revenue was invented: a zero rate must not read as "earns + // nothing", which would pause a rig for a fault that is the pool's. + assert!( + !cnf.runtime.paused_unprofitable.load(Relaxed), + "a header that cannot be priced is not evidence the rig is unprofitable" + ); + + // The previous decision also stands untouched, in either direction. + cnf.runtime.paused_unprofitable.store(true, Relaxed); + let mut win2 = BlockMiningResult::default(); + win2.height = 500; + win2.prevhash = vec![0x11u8; HASH_WIDTH]; + win2.nonce_space = 1; + win2.use_secs = 0.5; + win2.head_nonce = 78; + win2.coinbase_nonce = vec![0x06; HASH_WIDTH]; + win2.target_hash = vec![0x0f; HASH_WIDTH]; + win2.result_hash = vec![0x02; HASH_WIDTH]; + win2.network_difficulty = 0; + let (res_tx2, mut res_rx2) = mpsc::sync_channel::>(4); + res_tx2.send(Arc::new(win2)).unwrap(); + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut res_rx2, + 1, + &mut HashrateTracker::default(), + 2, + &submit_tx, + &test_gate(), + ); + assert!( + cnf.runtime.paused_unprofitable.load(Relaxed), + "an unpriceable header must not silently resume a rig its operator's \ + own numbers had paused" + ); + } + + #[test] + fn a_profit_pause_is_never_a_one_way_latch() { + let _guard = mining_state_guard(); + set_pending_block_stuff(500, pending_template_json(500, 0x11, 0xb1)).unwrap(); + + const NETWORK_DIFFICULTY: u32 = 0xD080_0000; + let mut cnf = PoWorkConf::test_defaults("127.0.0.1:1".to_string(), 1, 16); + cnf.pool_worker = "1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(); + cnf.efficiency.pause_if_unprofitable = true; + cnf.efficiency.hac_price = 1.0; + cnf.efficiency.power_cost_kwh = 0.15; + cnf.efficiency.gpu_watts = 100.0; + cnf.efficiency.cpu_watts_per_thread = 0.0; + + // One tick that really is unprofitable: the rig pauses, correctly. + let mut small = BlockMiningResult::default(); + small.height = 500; + small.prevhash = vec![0x11u8; HASH_WIDTH]; + small.nonce_space = 1_000_000; + small.use_secs = 1.0; + small.target_hash = vec![0x0fu8; HASH_WIDTH]; + small.result_hash = vec![0x7fu8; HASH_WIDTH]; + small.network_difficulty = NETWORK_DIFFICULTY; + + let (tx, mut rx) = mpsc::sync_channel::>(4); + tx.send(Arc::new(small)).unwrap(); + let (submit_tx, _submit_rx) = mpsc::sync_channel::>(4); + let mut most_hash = vec![255u8; HASH_WIDTH]; + let mut tracker = HashrateTracker::default(); + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut rx, + 1, + &mut tracker, + 1_000, + &submit_tx, + &test_gate(), + ); + assert!(cnf.runtime.paused_unprofitable.load(Relaxed)); + + // Paused workers produce nothing, so every later tick drains an EMPTY + // channel, and that is the only state this rig can now be in. Before the + // recheck interval the pause has to hold, or it is not a pause at all. + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut rx, + 1, + &mut tracker, + 1_000 + PROFIT_PAUSE_RECHECK_MS - 1, + &submit_tx, + &test_gate(), + ); + assert!( + cnf.runtime.paused_unprofitable.load(Relaxed), + "the pause must actually pause: lifting it on the next empty tick \ + would make it worthless" + ); + + // After it, the rig has to be allowed to measure again. Without that it + // is finished for the life of the process even if HAC doubles in price, + // because the line that clears the flag sits past the empty-drain return + // and only a result can reach it. + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut rx, + 1, + &mut tracker, + 1_000 + PROFIT_PAUSE_RECHECK_MS + 1, + &submit_tx, + &test_gate(), + ); + assert!( + !cnf.runtime.paused_unprofitable.load(Relaxed), + "the profit pause is cleared only by a result, and a paused rig \ + produces none: without a recheck it can never mine again without a \ + restart" + ); + } + #[test] fn a_winner_mined_just_before_the_tip_advanced_is_still_submitted() { let _guard = mining_state_guard(); @@ -2190,6 +2591,44 @@ mod tests { assert!(!result_meets_target(&res)); } + #[test] + fn debug_mode_never_submits_a_losing_hash() { + let _guard = mining_state_guard(); + set_pending_block_stuff(900, pending_template_json(900, 0x11, 0xb1)).unwrap(); + + let mut cnf = PoWorkConf::test_defaults("127.0.0.1:1".to_string(), 1, 16); + cnf.debug = 1; + let mut losing = BlockMiningResult::default(); + losing.height = 900; + losing.prevhash = vec![0x11u8; HASH_WIDTH]; + losing.nonce_space = 1; + losing.use_secs = 0.5; + losing.target_hash = vec![0x0fu8; HASH_WIDTH]; + losing.result_hash = vec![0x10u8; HASH_WIDTH]; + losing.network_difficulty = 1; + + let (result_tx, mut result_rx) = mpsc::sync_channel(1); + result_tx.send(Arc::new(losing)).unwrap(); + let (submit_tx, submit_rx) = mpsc::sync_channel(1); + let mut most_hash = vec![255u8; HASH_WIDTH]; + let mut tracker = HashrateTracker::default(); + deal_block_mining_results( + &cnf, + &mut most_hash, + &mut result_rx, + 1, + &mut tracker, + 1, + &submit_tx, + &test_gate(), + ); + + assert!( + submit_rx.try_recv().is_err(), + "debug mode must never bypass the proof-of-work target gate" + ); + } + #[test] fn a_panicking_iteration_never_ends_the_mining_thread() { let previous_hook = std::panic::take_hook(); diff --git a/app/src/cpu_threads.rs b/app/src/cpu_threads.rs new file mode 100644 index 00000000..07575474 --- /dev/null +++ b/app/src/cpu_threads.rs @@ -0,0 +1,247 @@ +//! How many CPU threads a worker takes, decided from the machine it is running +//! on rather than from a number somebody typed into a shipped config file. +//! +//! # What was measured +//! +//! HACD (diamond) hashrate on a Ryzen 9 9950X, 16 physical cores and 32 logical, +//! one binary, one process, nothing changed between rows but the thread count: +//! +//! ```text +//! threads H/s per thread vs 1 thread +//! 1 71,728 71,728 1.00x +//! 6 320,097 53,350 4.46x +//! 16 853,739 53,359 11.90x +//! 32 1,442,210 45,069 20.11x +//! ``` +//! +//! # All logical, not all physical, and why +//! +//! The 16 row is exactly the physical core count and the 32 row adds SMT: +69% +//! for threads that share an execution port with one already running. x16rs is a +//! long chain of small dependent hashes, so the second thread on a core fills +//! stalls the first one leaves rather than fighting it for issue slots. Stopping +//! at the physical count would give away 41% of this machine's diamond rate, so +//! the default counts LOGICAL CPUs. +//! +//! There is also nothing in std that reports physical cores: `available_parallelism` +//! reports logical ones. "All physical" would need a new dependency before it +//! could even be expressed, and the measurement says it would be the worse +//! answer, so this module never tries. +//! +//! # But not every logical thread +//! +//! Per-thread throughput has already fallen 37% by 32 threads, which makes the +//! last two threads on this CPU the cheapest ones on the machine to give back: +//! about 45k H/s out of 1.44M, near 3%. They buy the fullnode the scheduling it +//! needs to serve its own RPC and consensus work, they buy a co-running GPU +//! miner a thread to feed its card from, and they buy the operator a desktop +//! that still redraws. A miner that takes the very last thread is a miner people +//! turn off, which costs 100%. +//! +//! # The two answers are not the same number +//! +//! [`hacd_threads_for`] is for a process where the CPU IS the miner. CPU assist +//! next to a GPU is a different question and gets [`cpu_assist_threads_for`]: +//! there the CPU is helping a device worth several hundred CPU threads, and a +//! stalled GPU feed thread costs far more than the assist threads earn. That +//! number is deliberately a small fraction and is a safety choice, not a +//! measurement: nothing here was measured with a GPU in the same process. + +/// Logical threads left to everything that is not the hash loop: one for the +/// fullnode this worker talks to, one for a GPU feed thread or the desktop. +/// +/// Two, not one, and not four. See the module docs: on the measured CPU these +/// are worth about 3% of the diamond rate, and both of the things they pay for +/// are things whose absence costs much more than 3%. +pub const HOST_RESERVE_THREADS: u32 = 2; + +/// Logical CPUs this machine reports, or 1 when the OS will not say. +/// +/// 1 rather than a guessed 8: an unknown machine gets the answer that cannot +/// oversubscribe anything, and every caller below floors at 1 anyway. +pub fn logical_cpus() -> u32 { + std::thread::available_parallelism() + .map(|n| n.get() as u32) + .unwrap_or(1) +} + +/// HACD thread count for a machine with `logical` logical CPUs: all of them but +/// the host reserve, and never zero. +pub fn hacd_threads_for(logical: u32) -> u32 { + logical.saturating_sub(HOST_RESERVE_THREADS).max(1) +} + +/// HACD thread count for THIS machine. +pub fn hacd_threads() -> u32 { + hacd_threads_for(logical_cpus()) +} + +/// CPU-assist thread count next to a GPU, for a machine with `logical` logical +/// CPUs. +/// +/// A quarter of the machine, floored at 1 and never more than [`hacd_threads_for`] +/// would take. The quarter is inherited from the panel's old "Automatic CPU +/// assist (safe)" entry, which was already core-derived; what it did not have +/// was a ceiling that scaled, so it stopped at 8 threads no matter how large the +/// CPU was. The ceiling is gone, the fraction stays, because the fraction is the +/// part that was never wrong. +pub fn cpu_assist_threads_for(logical: u32) -> u32 { + (logical / 4).clamp(1, hacd_threads_for(logical)) +} + +/// CPU-assist thread count for THIS machine. +pub fn cpu_assist_threads() -> u32 { + cpu_assist_threads_for(logical_cpus()) +} + +/// Clamp a configured thread count down to what is safe to run beside a GPU. +/// +/// Zero is preserved: for a GPU miner, `supervene = 0` means "no CPU assist at +/// all", which is a real and common choice and must never be clamped UP into +/// spawning threads nobody asked for. +pub fn cap_cpu_assist_for(configured: u32, logical: u32) -> u32 { + if configured == 0 { + return 0; + } + configured.min(cpu_assist_threads_for(logical)) +} + +/// Clamp a configured CPU-assist count for THIS machine. +pub fn cap_cpu_assist(configured: u32) -> u32 { + cap_cpu_assist_for(configured, logical_cpus()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The measured machine. This is the row the whole module exists for: the + /// shipped default used to be 6 on it, which is 22% of what it can do. + #[test] + fn the_9950x_gets_thirty_of_its_thirty_two_threads() { + assert_eq!(hacd_threads_for(32), 30); + // 6 was the shipped number, 8 was the panel's "Automatic". Both are gone. + assert_ne!(hacd_threads_for(32), 6); + assert_ne!(hacd_threads_for(32), 8); + // And it is not the physical core count either: SMT measured +69%. + assert_ne!(hacd_threads_for(32), 16); + } + + /// Small and strange machines must still get a runnable answer, and must + /// never get one larger than the machine. + #[test] + fn every_machine_gets_at_least_one_thread_and_never_more_than_it_has() { + for logical in 0..=256u32 { + let hacd = hacd_threads_for(logical); + let assist = cpu_assist_threads_for(logical); + assert!(hacd >= 1, "logical={logical}"); + assert!(assist >= 1, "logical={logical}"); + if logical >= 1 { + assert!(hacd <= logical, "logical={logical}"); + assert!(assist <= logical, "logical={logical}"); + } + assert!(assist <= hacd, "logical={logical}"); + } + } + + /// A bigger CPU must never be given fewer threads than a smaller one. The + /// old panel list failed this: it capped "Automatic" at 8 forever. + #[test] + fn the_answer_never_shrinks_as_the_machine_grows() { + for logical in 1..=256u32 { + assert!( + hacd_threads_for(logical) >= hacd_threads_for(logical - 1), + "logical={logical}" + ); + assert!( + cpu_assist_threads_for(logical) >= cpu_assist_threads_for(logical - 1), + "logical={logical}" + ); + } + // The specific failure that is being fixed: the old rule was + // (logical / 4).clamp(2, 8), which returns 8 for 32 and for 128 alike. + assert!(cpu_assist_threads_for(128) > cpu_assist_threads_for(32)); + } + + /// The reserve is the whole point of the HACD number, so it is asserted + /// rather than left to the arithmetic. + #[test] + fn the_host_reserve_is_left_free_on_any_machine_big_enough_to_spare_it() { + for logical in (HOST_RESERVE_THREADS + 1)..=256u32 { + assert_eq!( + logical - hacd_threads_for(logical), + HOST_RESERVE_THREADS, + "logical={logical}" + ); + } + } + + /// HACD and CPU assist answer different questions, and the assist answer has + /// to be the smaller one on any machine where "smaller" exists. If these + /// ever collapse into one number, a GPU rig starts running the diamond + /// miner's thread count next to its card. + #[test] + fn cpu_assist_is_a_fraction_of_the_hacd_count_on_a_real_cpu() { + for logical in [8u32, 12, 16, 24, 32, 64, 128] { + assert!( + cpu_assist_threads_for(logical) * 2 <= hacd_threads_for(logical), + "logical={logical}" + ); + } + assert_eq!(cpu_assist_threads_for(32), 8); + assert_eq!(cpu_assist_threads_for(16), 4); + } + + /// The reserve exists to be spent on exactly two things, so this is the + /// arithmetic it has to survive: HACD taking its default while the GPU block + /// miner runs on the same box with one thread per card to feed it, and the + /// fullnode both of them talk to running too. + /// + /// It holds for one GPU, which is what the reserve is sized for. A rig with + /// several cards, or one that also turns on poworker's `cpu_assist`, is + /// spending the same two threads more than once; that is why the diamond + /// worker prints what it took and what it left. + #[test] + fn the_hacd_default_leaves_room_for_a_gpu_feed_thread_and_the_node() { + for logical in 4..=256u32 { + let hacd = hacd_threads_for(logical); + let one_gpu_feed = 1; + let fullnode = 1; + assert!( + hacd + one_gpu_feed + fullnode <= logical, + "logical={logical}: HACD takes {hacd} and leaves no room to co-run" + ); + } + // The shipped GPU-first configs default `cpu_assist = false`, so a + // co-running poworker costs exactly the feed thread this reserves. + assert_eq!(hacd_threads_for(32) + 1 + 1, 32); + } + + /// "No CPU assist" is a choice, not a missing value. + #[test] + fn capping_cpu_assist_never_invents_threads() { + for logical in 1..=64u32 { + assert_eq!(cap_cpu_assist_for(0, logical), 0, "logical={logical}"); + for configured in 1..=64u32 { + let capped = cap_cpu_assist_for(configured, logical); + assert!(capped >= 1); + assert!(capped <= configured); + assert!(capped <= cpu_assist_threads_for(logical)); + } + } + // The case this exists for: an operator picks the full HACD ladder entry + // while a GPU is selected. 30 assist threads beside a card is not what + // the picker meant. + assert_eq!(cap_cpu_assist_for(30, 32), 8); + } + + /// This machine, whatever it is, must produce something usable. Catches a + /// platform where `available_parallelism` fails and the fallback is wrong. + #[test] + fn this_machine_gets_a_usable_answer() { + assert!(logical_cpus() >= 1); + assert!(hacd_threads() >= 1); + assert!(cpu_assist_threads() >= 1); + assert!(cpu_assist_threads() <= hacd_threads()); + } +} diff --git a/app/src/diaworker.rs b/app/src/diaworker.rs index 5b11f768..5121d0fe 100644 --- a/app/src/diaworker.rs +++ b/app/src/diaworker.rs @@ -19,7 +19,7 @@ use mint::action::*; use mint::genesis::*; use sys::*; -use crate::hash_util::diamond_better; +use crate::hash_util::{DiamondSha3Gate, diamond_better, diamond_name_is_valid}; #[cfg(feature = "ocl")] use crate::gpu_oom::GpuBatchError; @@ -60,7 +60,19 @@ impl DiaWorkConf { pub fn new(ini: &IniObj) -> DiaWorkConf { let sec = &ini_section(ini, "default"); // default = root let efficiency = EfficiencyConf::from_ini(ini); - let configured_supervene = (ini_must_u64(sec, "supervene", 2) as u32).max(1); + // An absent `supervene`, or an explicit 0, means "fit this machine". + // + // Both used to end at 1 thread: the key defaulted to 2 and 0 was raised + // to 1 by `.max(1)`. Neither is a choice anybody made. HACD is CPU-only, + // so 0 cannot mean "no CPU mining" the way it does for the GPU block + // miner; it can only mean the operator never set a number. A shipped ini + // cannot count the cores of a machine it has never seen, so it says 0 and + // this does the counting. See `crate::cpu_threads` for the measurement + // that fixes the reserve at two logical threads. + let configured_supervene = match ini_must_u64(sec, "supervene", 0) as u32 { + 0 => crate::cpu_threads::hacd_threads(), + explicit => explicit, + }; let active = efficiency.initial_active_supervene(configured_supervene); let runtime = MiningRuntimeState::new(0, active); // HACD is officially CPU/full-node mining. Legacy GPU keys are ignored @@ -80,7 +92,11 @@ impl DiaWorkConf { ); } DiaWorkConf { - rpcaddr: ini_must(sec, "connect", "127.0.0.1:8081"), + // Normalised ONCE, here, so no request site can build a URL its own + // way. A bare host:port stays plain HTTP, which is what every + // existing config has; https:// now works instead of being pasted + // inside another scheme. + rpcaddr: crate::rpc_http::base_url(&ini_must(sec, "connect", "127.0.0.1:8081")), api_token: ini_must(sec, "api_token", "").trim().to_string(), supervene: configured_supervene, bidaddr: Address::default(), @@ -108,12 +124,19 @@ mod config_tests { use super::*; - #[test] - fn legacy_hacd_gpu_config_is_forced_to_cpu_only() { + fn ini_with_root(entries: &[(&str, &str)]) -> IniObj { let mut ini = IniObj::new(); let mut default = HashMap::new(); - default.insert("supervene".to_string(), Some("0".to_string())); + for (k, v) in entries { + default.insert(k.to_string(), Some(v.to_string())); + } ini.insert("default".to_string(), default); + ini + } + + #[test] + fn legacy_hacd_gpu_config_is_forced_to_cpu_only() { + let mut ini = ini_with_root(&[("supervene", "0")]); let mut gpu = HashMap::new(); gpu.insert("use_opencl".to_string(), Some("true".to_string())); gpu.insert("cpu_assist".to_string(), Some("true".to_string())); @@ -122,7 +145,9 @@ mod config_tests { ini.insert("gpu".to_string(), gpu); let config = DiaWorkConf::new(&ini); - assert_eq!(config.supervene, 1); + // 0 is not a thread count here, it is an unanswered question, and the + // answer is this machine. It used to be silently raised to 1. + assert_eq!(config.supervene, crate::cpu_threads::hacd_threads()); assert!(!config.useopencl); assert!(!config.cpu_assist); assert_eq!(config.workgroups, 0); @@ -130,6 +155,50 @@ mod config_tests { assert_eq!(config.gpu_slug, "none"); assert!(config.gpu_profile.is_empty()); } + + /// A config that says nothing about threads must not get the number 2, and + /// must not get the number 6 that used to ship. It gets the machine. + #[test] + fn a_config_without_a_thread_count_fits_the_machine_it_runs_on() { + let auto = DiaWorkConf::new(&ini_with_root(&[])).supervene; + assert_eq!(auto, crate::cpu_threads::hacd_threads()); + assert!(auto >= 1); + let logical = crate::cpu_threads::logical_cpus(); + if logical > crate::cpu_threads::HOST_RESERVE_THREADS { + assert_eq!(auto, logical - crate::cpu_threads::HOST_RESERVE_THREADS); + } + } + + /// Auto is a default, never an override. An operator who typed a number owns + /// it, including a deliberately small one on a large machine. + #[test] + fn an_explicit_thread_count_is_obeyed_exactly() { + for n in [1u32, 2, 3, 6, 12, 30, 64, 255] { + let config = DiaWorkConf::new(&ini_with_root(&[("supervene", &n.to_string())])); + assert_eq!(config.supervene, n, "supervene = {n}"); + } + } + + /// `initial_active_supervene` runs over the auto value, so a default-shaped + /// efficiency section must not quietly clamp the machine back down. This is + /// the second half of the shipped-config bug: `supervene_max = 0` means + /// "uncapped", and if that ever changed to mean "zero" the auto count would + /// vanish. + #[test] + fn the_auto_count_survives_the_shipped_efficiency_section() { + let mut ini = ini_with_root(&[]); + let mut eff = HashMap::new(); + eff.insert("supervene_min".to_string(), Some("1".to_string())); + eff.insert("supervene_max".to_string(), Some("0".to_string())); + eff.insert("dynamic_supervene".to_string(), Some("false".to_string())); + ini.insert("efficiency".to_string(), eff); + + let config = DiaWorkConf::new(&ini); + let auto = crate::cpu_threads::hacd_threads(); + assert_eq!(config.supervene, auto); + assert_eq!(config.efficiency.clamp_supervene(config.supervene), auto); + assert_eq!(config.runtime.active_cpu_assist.load(Relaxed), auto); + } } /*************************************/ @@ -524,7 +593,19 @@ pub fn diaworker_with_stop(stop_flag: Option>) { } } else { let thrnum = cnf.efficiency.clamp_supervene(cnf.supervene) as usize; - wlogln!("\n[Start] Create #{} diamond miner worker thread.", thrnum); + // Say what was taken and what was left. An operator who also runs the + // GPU block miner on this box needs to see this arithmetic: the two + // threads left free are one for the fullnode and one for a GPU feed + // thread, and enabling `cpu_assist` in poworker.config.ini as well would + // spend them twice. + let logical = crate::cpu_threads::logical_cpus(); + wlogln!( + "\n[Start] Create #{} diamond miner worker thread ({} of {} logical CPUs, {} left free).", + thrnum, + thrnum, + logical, + logical.saturating_sub(thrnum as u32) + ); for thrid in 0..thrnum { let cnf2 = cnf.clone(); let rstx = res_tx.clone(); @@ -882,8 +963,6 @@ fn do_diamond_group_mining( nonce_space: u64, ) -> DiamondMiningResult { let empthbytes = [0u8; 0]; - let prevhash: &[u8; HASH_WIDTH] = prevblockhash; - let address: &[u8; 21] = rwdaddr; let custom_nonce: &[u8] = maybe!( number > DIAMOND_ABOVE_NUMBER_OF_CREATE_BY_CUSTOM_MESSAGE, custom_message.as_bytes(), @@ -906,18 +985,71 @@ fn do_diamond_group_mining( let mut most_diastr = [b'W'; DIAMOND_HASH_LEN]; let mut most_noncebytes = [0u8; 8]; + // Everything below that depends only on `number` is hoisted out of the nonce + // loop: the x16rs round count and the two step-1 difficulty terms are + // constant for the whole round. + let repeat = x16rs::mine_diamond_hash_repeat(number); + let gate = DiamondSha3Gate::for_number(number); + // The pre-image is built ONCE and only bytes 32..40 (the big-endian nonce) + // are overwritten per attempt. `diamond_pre_image` is the single place both + // the CPU and the OpenCL path build it, and its doc comment records that it + // is byte-identical to what `x16rs::mine_diamond` concatenates, so this + // stays consensus-identical while dropping the five Vec allocations + // `mine_diamond` makes per nonce (four `to_vec` plus the `concat`). + let mut stuff = diamond_pre_image(prevblockhash, &[0u8; 8], rwdaddr, custom_nonce); + // start mining for nonce in nonce_start..nonce_start.saturating_add(nonce_space) { // std::thread::sleep(std::time::Duration::from_micros(333)); // test let nonce_bytes = nonce.to_be_bytes(); - let (firhx, resxh, diastr) = - x16rs::mine_diamond(number, prevhash, &nonce_bytes, address, custom_nonce); + stuff[HASH_WIDTH..HASH_WIDTH + 8].copy_from_slice(&nonce_bytes); + // SHA3. Part of the 2.7% of an attempt that is not x16rs; measured at + // 361 ns for everything-except-x16rs against 12,943 ns for x16rs itself. + let firhx = x16rs::calculate_hash(&stuff); + + // THE PREFILTER, and the only reason HACD mining got faster. + // + // Step 1 of `x16rs::check_diamond_difficulty` reads ONLY this sha3 hash; + // it never looks at the x16rs hash. So when it fails, that function + // returns false for every possible x16rs hash, and the `repeat` rounds + // of x16rs below (97.3% of an attempt, 17 rounds at diamond 133,700) + // cannot produce a diamond no matter what they compute. Skipping them is + // therefore not an approximation: the set of accepted diamonds is + // unchanged. That implication is proved, quantified over the x16rs hash, + // by `a_failing_gate_forbids_every_possible_x16rs_hash` in hash_util.rs, + // and the converse (the gate never rejects what the original accepts) by + // `the_gate_never_rejects_a_nonce_the_original_accepts`. Below diamond + // 42,000 the gate passes everything and costs 32 byte comparisons. + // + // ONE BEHAVIOUR DOES CHANGE, and an operator will see it: `most.dia_str`, + // the "best so far" string in the console line and in mining_stats, now + // only ever reflects the nonces that passed the gate (~11% at 133,700, + // ~1% at 300,000), so it will look weaker than it used to. That is + // DISPLAY ONLY. `most.dia_str` reaches exactly three places -- the + // `diamond_better` comparisons here and in `deal_diamond_mining_results`, + // the `flush!` console line, and `mining_stats::emit_from_batch_aggregate` + // -- and never `is_success`, `check_diamer_success`, or a submission. + // The local `most_diastr` below does feed `check_diamer_success` after + // the loop, but that call is the same conjunction as the winner test + // inside the loop, so it can only return Some for a nonce that already + // hit the `break` -- and a gate-failing nonce can never be that nonce. + if !gate.passes(&firhx) { + continue; + } + + let resxh = x16rs::x16rs_hash(repeat, &firhx); + let diastr = x16rs::diamond_hash(&resxh); // A valid diamond has EXACTLY DMD_L leading zeros followed by a non-zero name. The // "most powerful" heuristic below maximises leading zeros, which overshoots into invalid // territory once difficulty is low (LOCAL TESTNET only). Test each candidate for validity // directly and take the first one that actually qualifies. - if x16rs::check_diamond_hash_result(&diastr).is_some() - && x16rs::check_diamond_difficulty(number, &firhx, &resxh) + // + // `diamond_name_is_valid` replaces `check_diamond_hash_result(..).is_some()` + // (which allocated a Vec per attempt). The two differ on arbitrary bytes, + // but agree on every output of `diamond_hash`, whose alphabet is exactly + // DIAMOND_HASH_BASE_CHARS; both halves of that argument are asserted in + // `diamond_name_is_valid_matches_check_diamond_hash_result_on_diamond_hash_output`. + if diamond_name_is_valid(&diastr) && x16rs::check_diamond_difficulty(number, &firhx, &resxh) { most.u64_nonce = nonce; most.dia_str = diastr.clone(); @@ -1003,7 +1135,7 @@ pub(crate) fn check_diamer_success( } fn load_init(cnf: &mut DiaWorkConf) { - let urlapi_pending = format!("http://{}/query/diamondminer/init", &cnf.rpcaddr); + let urlapi_pending = format!("{}/query/diamondminer/init", &cnf.rpcaddr); loop { let body = match crate::rpc_http::get_text(&HTTP_CLIENT, &urlapi_pending, &cnf.api_token, None) { @@ -1011,7 +1143,8 @@ fn load_init(cnf: &mut DiaWorkConf) { Err(e) => { wlogln!( "Error: cannot init diamond miner from {}: {}", - &urlapi_pending, e + &urlapi_pending, + e ); delay_continue!(30); } @@ -1038,7 +1171,8 @@ fn load_init(cnf: &mut DiaWorkConf) { }; wlogln!( "[Config] query diamond miner bid address: {}, reward address: {}", - &adr1, &adr2 + &adr1, + &adr2 ); // ok cnf.bidaddr = bid_addr; @@ -1051,7 +1185,7 @@ fn load_init(cnf: &mut DiaWorkConf) { fn pull_and_push_diamond(cnf: &DiaWorkConf) { let mining_num = MINING_DIAMOND_NUM.load(Acquire); - let urlapi_latest = format!("http://{}/query/latest", &cnf.rpcaddr); + let urlapi_latest = format!("{}/query/latest", &cnf.rpcaddr); // get next number // wlogln!("urlapi_latest: {}", &urlapi_latest); let body = match crate::rpc_http::get_text(&HTTP_CLIENT, &urlapi_latest, &cnf.api_token, None) { @@ -1082,15 +1216,12 @@ fn pull_and_push_diamond(cnf: &DiaWorkConf) { } else if next_num < mining_num { wlogln!( "[HACD] diamond tip reorg: number {} -> {}, refreshing job", - mining_num, next_num + mining_num, + next_num ); } // query prev diamond (or re-query when number did not advance) - let urlapi_diamond = format!( - "http://{}/query/diamond?number={}", - &cnf.rpcaddr, - next_num - 1 - ); + let urlapi_diamond = format!("{}/query/diamond?number={}", &cnf.rpcaddr, next_num - 1); // wlogln!("urlapi_diamond: {}", &urlapi_diamond); let body = match crate::rpc_http::get_text(&HTTP_CLIENT, &urlapi_diamond, &cnf.api_token, None) { @@ -1109,7 +1240,8 @@ fn pull_and_push_diamond(cnf: &DiaWorkConf) { let Ok(hx) = hex::decode(&prev_hash) else { wlogln!( "Error: cannot get born.hash from {}: {:?}", - &urlapi_diamond, &res + &urlapi_diamond, + &res ); delay_return!(30); // hash error }; @@ -1133,7 +1265,7 @@ fn pull_and_push_diamond(cnf: &DiaWorkConf) { } fn push_diamond_mining_success(cnf: &DiaWorkConf, success: DiamondMint) { - let urlapi_success = format!("http://{}/submit/diamondminer/success", &cnf.rpcaddr); + let urlapi_success = format!("{}/submit/diamondminer/success", &cnf.rpcaddr); let actionbody = success.serialize(); // Submitting the mined diamond is the whole payoff. Match the block submit // path: retry transport failures AND unrecognized HTTP-200 bodies (proxy @@ -1384,6 +1516,14 @@ mod diamond_job_reorg_tests { let pre_image = diamond_pre_image(&prev, &nonce_bytes, &addr, custom_nonce); assert_eq!(pre_image.len(), if with_custom { 93 } else { 61 }); + // The CPU nonce loop builds the pre-image once and then splices the + // big-endian nonce into bytes 32..40 in place. Pin that the spliced + // buffer is byte-identical to a freshly built one, because the whole + // hot loop now depends on those being the same 61 (or 93) bytes. + let mut spliced = diamond_pre_image(&prev, &[0u8; 8], &addr, custom_nonce); + spliced[HASH_WIDTH..HASH_WIDTH + 8].copy_from_slice(&nonce_bytes); + assert_eq!(spliced, pre_image); + let (ssshash, reshash, diastr) = x16rs::mine_diamond(number, &prev, &nonce_bytes, &addr, custom_nonce); // Same SHA3 input as consensus. @@ -1439,6 +1579,166 @@ mod diamond_job_reorg_tests { } } +#[cfg(test)] +mod diamond_prefilter_tests { + use super::*; + + fn fixture(number: u32) -> (Hash, Address, Hash, Vec) { + let prev = Hash::from([0x5au8; HASH_WIDTH]); + let addr = Address::from([0x11u8; 21]); + let custom = Hash::from([0x7cu8; HASH_WIDTH]); + let cm: Vec = if number > DIAMOND_ABOVE_NUMBER_OF_CREATE_BY_CUSTOM_MESSAGE { + custom.as_bytes().to_vec() + } else { + Vec::new() + }; + (prev, addr, custom, cm) + } + + const CORPUS_NUMBERS: [(u32, u64); 10] = [ + (1, 20_000), + (41_999, 20_000), + (42_000, 20_000), + (65_535, 20_000), + (65_536, 20_000), + (65_537, 20_000), + (84_000, 20_000), + (133_700, 30_000), + (210_000, 20_000), + (300_000, 12_000), + ]; + + /// End to end on the REAL function, and this test exists because its absence + /// was a hole big enough to ship a miner that never finds anything. + /// + /// An adversarial pass broke the hot loop five ways and the entire 306-test + /// suite stayed green: the nonce splice off by one in each direction, the + /// splice deleted, the nonce written little-endian, and worst, + /// `if !gate.passes` inverted to `if gate.passes`, which computes x16rs + /// ONLY for nonces that provably cannot mint. That last one runs at 8x, + /// prints a plausible hashrate, and finds a diamond never. + /// + /// The cause was that nothing called `do_diamond_group_mining`. The other + /// tests re-implement the loop inside themselves, so they pin the idea and + /// not the code. This one calls the real function and holds it to its own + /// output: whatever nonce + /// `do_diamond_group_mining` reports as its best must, when handed back to + /// x16rs::mine_diamond, reproduce exactly the dia_str it reported. A + /// mis-spliced nonce buffer shows up here even though the internal + /// comparison in audit_23 would still be self-consistent. + #[test] + fn audit_2e_the_real_mining_function_reports_a_nonce_that_reproduces_its_hash() { + for (number, n_nonce) in CORPUS_NUMBERS { + let (prev, addr, custom, cm) = fixture(number); + let space = n_nonce.min(8_000); + for start in [0u64, 1, 4096, 1_000_000_000] { + let res = do_diamond_group_mining(number, &prev, &addr, &custom, start, space); + assert_eq!(res.number, number); + assert_eq!(res.msg_nonce, cm); + // The reported best must be a real nonce in the window. + assert!( + res.u64_nonce >= start && res.u64_nonce < start + space, + "n={number} start={start} reported nonce {} outside window", + res.u64_nonce + ); + let (_f, _r, dia) = + x16rs::mine_diamond(number, &prev, &res.u64_nonce.to_be_bytes(), &addr, &cm); + assert_eq!( + dia, + res.dia_str, + "n={number} start={start} nonce={} reported {:?} but mine_diamond says {:?}", + res.u64_nonce, + String::from_utf8_lossy(&res.dia_str), + String::from_utf8_lossy(&dia) + ); + // And it really is the best over the gate-passing nonces. + let gate = DiamondSha3Gate::for_number(number); + let repeat = x16rs::mine_diamond_hash_repeat(number); + let mut best = [b'W'; DIAMOND_HASH_LEN]; + for nonce in start..start + space { + let (f, r, d) = + x16rs::mine_diamond(number, &prev, &nonce.to_be_bytes(), &addr, &cm); + let _ = (r, repeat); + if !gate.passes(&f) { + continue; + } + if diamond_better(&d, &best) { + best = d; + } + } + assert_eq!( + best, res.dia_str, + "n={number} start={start}: best over gate-passing nonces disagrees" + ); + } + } + println!("AUDIT2e ok"); + } + + /// The equivalence proof in hash_util.rs quantifies over the x16rs hash on + /// synthetic inputs. This is the same claim on REAL data: real SHA3 outputs + /// from the real pre-image, at a real diamond number, checked against the + /// real `check_diamer_success` the submit path uses. + /// + /// If this ever fails, the prefilter is dropping a mintable nonce and HACD + /// mining is losing money. + #[test] + fn the_prefilter_never_skips_a_nonce_that_could_have_minted() { + // 133,700 is the measured point: repeat 17, gate pass rate ~10.9%. + let number = 133_700u32; + let prev = Hash::from([0x5au8; HASH_WIDTH]); + let addr = Address::from([0x11u8; 21]); + let custom = Hash::from([0u8; HASH_WIDTH]); + let custom_nonce: &[u8] = if number > DIAMOND_ABOVE_NUMBER_OF_CREATE_BY_CUSTOM_MESSAGE { + custom.as_bytes() + } else { + &[] + }; + let gate = DiamondSha3Gate::for_number(number); + let repeat = x16rs::mine_diamond_hash_repeat(number); + let mut stuff = diamond_pre_image(&prev, &[0u8; 8], &addr, custom_nonce); + + let mut skipped = 0usize; + let mut kept = 0usize; + for nonce in 0u64..3000 { + let nonce_bytes = nonce.to_be_bytes(); + stuff[HASH_WIDTH..HASH_WIDTH + 8].copy_from_slice(&nonce_bytes); + let firhx = x16rs::calculate_hash(&stuff); + if gate.passes(&firhx) { + kept += 1; + continue; + } + skipped += 1; + // The work the prefilter skipped, done in full: if it could ever have + // produced a diamond, this is where it shows up. + let resxh = x16rs::x16rs_hash(repeat, &firhx); + let diastr = x16rs::diamond_hash(&resxh); + assert!( + !x16rs::check_diamond_difficulty(number, &firhx, &resxh), + "prefilter skipped nonce {nonce} which passes the real difficulty check" + ); + assert!( + check_diamer_success(number, firhx, resxh, diastr).is_none(), + "prefilter skipped nonce {nonce} which is a mintable diamond" + ); + // And the winner test the loop actually uses agrees with the one it + // replaced, on real diamond_hash output. + assert_eq!( + diamond_name_is_valid(&diastr), + x16rs::check_diamond_hash_result(diastr).is_some() + ); + } + // Non-vacuity, and the documented saving: the gate must reject the large + // majority at this number (measured ~89.1%) while still passing some. + assert!(kept > 0, "the gate rejected every nonce"); + assert!( + skipped * 10 > (skipped + kept) * 8, + "gate rejected only {skipped} of {} nonces; expected ~89% at number {number}", + skipped + kept + ); + } +} + fn run_diamond_mining_benchmark(cnf: &DiaWorkConf, config_path: &str) { #[cfg(not(feature = "ocl"))] { diff --git a/app/src/efficiency.rs b/app/src/efficiency.rs index 5307a917..f480ccb7 100644 --- a/app/src/efficiency.rs +++ b/app/src/efficiency.rs @@ -164,10 +164,46 @@ impl EfficiencyConf { profile_watts * (0.45 + 0.55 * load.sqrt()) } + /// What the GPUs are drawing, preferring the sensor over the guess. + /// + /// `measured_gpu_w` is the rig's real total board power where a card + /// reported one. Every decision that used to be made on `gpu_watts` from the + /// ini goes through here instead, so a measured rig and an unmeasured one + /// differ in the number, never in the code path. + pub fn gpu_watts_now(&self, profile: &str, measured_gpu_w: Option) -> f64 { + match crate::mining_stats::sensor_board_power_w(measured_gpu_w) { + Some(measured) => f64::from(measured), + None => self.estimate_gpu_watts(profile), + } + } + + /// Whole-rig draw: the GPUs (measured where possible) plus the CPU threads + /// assisting them, whose draw is always the configured per-thread estimate. + pub fn total_watts_now( + &self, + profile: &str, + active_cpu_threads: u32, + measured_gpu_w: Option, + ) -> f64 { + self.gpu_watts_now(profile, measured_gpu_w) + + active_cpu_threads as f64 * self.cpu_watts_per_thread + } + + /// Daily electricity cost, on the measurement where there is one. + pub fn daily_power_cost_eur_now( + &self, + profile: &str, + active_cpu_threads: u32, + measured_gpu_w: Option, + ) -> f64 { + self.total_watts_now(profile, active_cpu_threads, measured_gpu_w) * 24.0 / 1000.0 + * self.power_cost_kwh + } + + /// Daily electricity cost with nothing measured, which is what a caller that + /// has no sensor reading to offer is really asking for. pub fn daily_power_cost_eur(&self, profile: &str, active_cpu_threads: u32) -> f64 { - let gpu_w = self.estimate_gpu_watts(profile); - let cpu_w = active_cpu_threads as f64 * self.cpu_watts_per_thread; - (gpu_w + cpu_w) * 24.0 / 1000.0 * self.power_cost_kwh + self.daily_power_cost_eur_now(profile, active_cpu_threads, None) } pub fn hashes_per_joule(&self, hashrate: f64, profile: &str, active_cpu_threads: u32) -> f64 { @@ -236,18 +272,47 @@ pub fn resolve_gpu_tuning( } /// Fixed work_groups / unit_size for named gpu_profile presets. +/// +/// # None of these is a measurement, and the NVIDIA rows say so out loud +/// +/// Exactly one shape in this whole table has ever been measured against +/// alternatives on the hardware it names: the RDNA4 path, which does not come +/// through here at all (see `panel_tuning::resolve_panel_tuning`, which pins the +/// RX 9070 XT to 64 x 256 x 192 with the sweep that produced it quoted). Every +/// other row is a starting point for the tuner. That was true before this +/// comment existed; what was missing was anything saying it. +/// +/// The NVIDIA rows are now derived rather than invented, and +/// [`crate::nvidia_launch::PRESET_LADDER`] carries the derivation line by line. +/// The short version: the batch kernel takes 255 registers on 256 threads, which +/// is the entire 65536-register file of an NVIDIA multiprocessor, so exactly one +/// block is resident per SM on every architecture from Volta to Blackwell. That +/// makes work_groups a WAVE COUNT above the SM count and nothing else, and the +/// p95 batch-latency ceiling caps it at about 17 waves at unit_size 64 - a +/// figure that is card-size independent because both the batch and the rate +/// scale with the multiprocessor count. The ladder spans that bracket. +/// +/// unit_size is 64 on every NVIDIA tier because 64 is the only NVIDIA value +/// anyone has measured to win: a Tesla T4 at repeat 16 gave 7.54 MH/s at 64, +/// 7.19 at 96 and 7.06 at 128. The table this replaces named 96 or 128 on all +/// five tiers, so every NVIDIA operator shipped on a value the one NVIDIA +/// measurement ranks last or second to last, and nvidia_max's 3584 x 256 x 128 +/// was a 15.6-second batch on that card against a 1.5-second ceiling. +/// +/// The AMD and Intel rows are untouched and remain unvalidated guesses. They are +/// not being changed here because nothing has been measured that would justify +/// a different guess, and swapping one invention for another would only move +/// which numbers look authoritative. pub fn profile_tuning(profile: &str) -> (u32, u32) { + if let Some(nvidia) = crate::nvidia_launch::preset_tuning(profile) { + return nvidia; + } match profile { "amd_eco" => (768, 128), "amd_balanced" => (1024, 128), "amd_profit" => (1536, 96), "amd_performance" => (2048, 96), "amd_max" => (4096, 128), - "nvidia_eco" => (512, 128), - "nvidia_balanced" => (1024, 128), - "nvidia_profit" => (1280, 96), - "nvidia_performance" => (1792, 96), - "nvidia_max" => (3584, 128), "intel_eco" => (384, 96), "intel_balanced" => (512, 128), "intel_profit" => (768, 96), @@ -659,7 +724,10 @@ pub fn apply_benchmark_pick(path: &str, pick: &BenchmarkPick) -> std::io::Result atomic_write_private(Path::new(path), out.as_bytes())?; wlogln!( "[benchmark] Applied gpu_profile={} (work_groups={}, unit_size={}) to {}", - pick.profile, pick.workgroups, pick.unitsize, path + pick.profile, + pick.workgroups, + pick.unitsize, + path ); Ok(()) } @@ -946,6 +1014,82 @@ enum GpuTempParser { Scalar, } +/// The widest window a graphics board's total draw can honestly fall in. +/// +/// It has to be wide: a card really does read 46 W on an idle desktop and 256 W +/// under the miner, and both are the same sensor telling the truth, so a window +/// tight enough to exclude "30" would throw away real idle readings. What it +/// excludes is the two ways this can be wrong rather than low. Zero is the value +/// an unsupported slot holds, and a board that is drawing power never reads +/// exactly zero, so zero is read as "no sensor" and not as "free electricity". A +/// thousand is above every board ever built (the largest data centre parts are +/// 750 W), so anything at or beyond it is a different sensor or a different +/// unit, not a draw. +/// +/// This is the one definition; the AMD driver path delegates to it, so a Windows +/// ADL reading and an `nvidia-smi` reading are judged by exactly the same rule. +pub fn plausible_board_power_w(value: f32) -> Option { + (value.is_finite() && value > 0.0 && value < 1000.0).then_some(value) +} + +/// A further query on the same tool, asking for one named quantity. +/// +/// Separate from the temperature query rather than folded into it because the +/// temperature parsers accept any labelled number, and a query that returned +/// both would let a watt figure be read as a temperature or the reverse. Each +/// query asks for exactly what its caller parses. +#[derive(Clone, Debug)] +struct ToolQuery { + program: &'static str, + args: Vec, +} + +/// One reading of everything a card will say about itself at an instant. +/// +/// Taken together rather than one quantity at a time because on the tools that +/// answer through a process (`nvidia-smi`) each separate query costs a spawn, +/// and because three numbers read seconds apart are not a sample of one moment. +/// Any field is `None` where this card or this tool does not report it. +#[derive(Clone, Copy, Debug, Default)] +pub struct GpuSample { + pub temp_c: Option, + pub watts: Option, + pub clock_mhz: Option, +} + +/// A shader clock that is a clock. Zero is what an idle or unsupported slot +/// reports, and no shipping GPU runs above 10 GHz. +fn plausible_clock_mhz(value: f32) -> Option { + (value.is_finite() && value > 0.0 && value < 10_000.0).then_some(value) +} + +/// The fields of a single `--format=csv,noheader,nounits` row. +/// +/// `nvidia-smi` prints `[N/A]` for a quantity the card does not expose, which +/// does not parse and therefore becomes `None` for that field alone: a card with +/// no power sensor still yields its temperature and its clock. +fn parse_csv_row(text: &str) -> Vec> { + let Some(first) = text.lines().map(str::trim).find(|line| !line.is_empty()) else { + return Vec::new(); + }; + first + .split(',') + .map(|field| field.trim().parse::().ok()) + .collect() +} + +/// Watts from a tool invoked with a power-only, unit-less, header-less query. +/// +/// `nvidia-smi --query-gpu=power.draw --format=csv,noheader,nounits` prints one +/// bare number, and prints `[N/A]` on the cards and virtualised setups that do +/// not expose the sensor. `[N/A]` does not parse, so it becomes `None` and the +/// caller falls back to the configured estimate and says that it did: absent +/// means absent, exactly as on the AMD path. +fn parse_board_power_output(text: &str) -> Option { + let first = text.lines().map(str::trim).find(|line| !line.is_empty())?; + plausible_board_power_w(first.parse::().ok()?) +} + #[derive(Clone, Debug)] enum GpuTempSensorSource { File(PathBuf), @@ -953,12 +1097,30 @@ enum GpuTempSensorSource { program: &'static str, args: Vec, parser: GpuTempParser, + /// The same tool's power query, where the tool has one. `None` for the + /// AMD `*-smi` sources, whose power reporting is not uniform across + /// rocm-smi and amd-smi versions, and inventing a watt figure from a + /// query we did not make would be a guess wearing a measurement's label. + power: Option, + /// One query that returns temperature, draw and shader clock together, + /// in that order. Only `nvidia-smi` has one, and it is what lets a tuner + /// sample a card once a second instead of spawning three processes for + /// three numbers taken at three different moments. + sample: Option, + /// The board power CAP, which is a static property of the card and its + /// configuration rather than a reading. Queried once. It is the + /// difference between "this shape drew 66 W" and "this shape sat on a + /// 70 W limit", which is the difference between a card that would go + /// faster with more work and one that would not. + power_limit: Option, }, /// The AMD display driver's own library, which is the only one of these /// that exists on a consumer Windows install. Bound to one ADL adapter at /// detection time, so every later read is the same physical card. #[cfg(windows)] - AmdDriver { adapter_index: i32 }, + AmdDriver { + adapter_index: i32, + }, } /// A sensor source selected once for one exact GPU and reused by the monitor. @@ -983,6 +1145,7 @@ impl GpuTempSensorBackend { program, args, parser, + .. } => { let output = command_stdout_with_timeout(program, args, SENSOR_COMMAND_TIMEOUT)?; let text = String::from_utf8_lossy(&output); @@ -999,6 +1162,102 @@ impl GpuTempSensorBackend { } } } + + /// The total board power this GPU is drawing right now, in watts, or `None` + /// where nothing under this backend measures it. + /// + /// Two sources can answer: the AMD display driver on Windows, and any + /// command source that was built with a power query of its own, which today + /// means `nvidia-smi`. The rest cannot, and say so rather than guessing: a + /// `thermal_file` is a single hwmon temperature and holds no power at all, + /// and the AMD `*-smi` sources are invoked here with temperature-only + /// queries, so a watt figure taken from them would be a guess wearing a + /// measurement's label. A `None` here is what makes the caller fall back to + /// the configured estimate and say that it did. + pub(crate) fn read_board_power_w(&self) -> Option { + match &self.source { + GpuTempSensorSource::File(_) => None, + GpuTempSensorSource::Command { power, .. } => { + let query = power.as_ref()?; + let output = command_stdout_with_timeout( + query.program, + &query.args, + SENSOR_COMMAND_TIMEOUT, + )?; + parse_board_power_output(&String::from_utf8_lossy(&output)) + } + #[cfg(windows)] + GpuTempSensorSource::AmdDriver { adapter_index } => { + crate::gpu_temp_adl::board_power_w(*adapter_index).and_then(plausible_board_power_w) + } + } + } + + /// Temperature, board draw and shader clock as of one moment. + /// + /// Where the backend has a combined query (`nvidia-smi`) or a driver library + /// that answers all three at once (ADL), this is ONE call and the three + /// numbers describe the same instant. Everywhere else it falls back to the + /// separate queries and reports no clock, because nothing under those + /// backends returns one and a fabricated clock would be worse than a missing + /// one: the auto-tuner treats an absent signal as "not judged" and an + /// invented one as evidence. + pub(crate) fn read_sample(&self) -> GpuSample { + match &self.source { + GpuTempSensorSource::Command { + sample: Some(query), + .. + } => { + let Some(output) = + command_stdout_with_timeout(query.program, &query.args, SENSOR_COMMAND_TIMEOUT) + else { + return GpuSample::default(); + }; + let fields = parse_csv_row(&String::from_utf8_lossy(&output)); + let at = |index: usize| fields.get(index).copied().flatten(); + GpuSample { + temp_c: at(0).and_then(valid_gpu_temp), + watts: at(1).and_then(plausible_board_power_w), + clock_mhz: at(2).and_then(plausible_clock_mhz), + } + } + #[cfg(windows)] + GpuTempSensorSource::AmdDriver { adapter_index } => { + match crate::gpu_temp_adl::sample(*adapter_index) { + Some(reading) => GpuSample { + temp_c: reading.temp_c.and_then(valid_gpu_temp), + watts: reading.board_power_w.and_then(plausible_board_power_w), + clock_mhz: reading.gfx_clock_mhz.and_then(plausible_clock_mhz), + }, + None => GpuSample::default(), + } + } + _ => GpuSample { + temp_c: self.read_c(), + watts: self.read_board_power_w(), + clock_mhz: None, + }, + } + } + + /// The board power CAP this card is running under, where the tool reports + /// one. A static property, so callers read it once rather than per sample. + pub(crate) fn read_power_limit_w(&self) -> Option { + match &self.source { + GpuTempSensorSource::Command { + power_limit: Some(query), + .. + } => { + let output = command_stdout_with_timeout( + query.program, + &query.args, + SENSOR_COMMAND_TIMEOUT, + )?; + parse_board_power_output(&String::from_utf8_lossy(&output)) + } + _ => None, + } + } } fn command_sensor( @@ -1013,10 +1272,51 @@ fn command_sensor( program, args, parser, + power: None, + sample: None, + power_limit: None, }, } } +impl GpuTempSensorBackend { + /// Attach the same tool's board-power query to a command sensor. + fn with_power_query( + mut self, + program: &'static str, + args: Vec, + ) -> GpuTempSensorBackend { + if let GpuTempSensorSource::Command { power, .. } = &mut self.source { + *power = Some(ToolQuery { program, args }); + } + self + } + + /// Attach the combined temperature/power/clock query. + fn with_sample_query( + mut self, + program: &'static str, + args: Vec, + ) -> GpuTempSensorBackend { + if let GpuTempSensorSource::Command { sample, .. } = &mut self.source { + *sample = Some(ToolQuery { program, args }); + } + self + } + + /// Attach the board power-limit query. + fn with_power_limit_query( + mut self, + program: &'static str, + args: Vec, + ) -> GpuTempSensorBackend { + if let GpuTempSensorSource::Command { power_limit, .. } = &mut self.source { + *power_limit = Some(ToolQuery { program, args }); + } + self + } +} + fn detect_first_sensor( candidates: Vec, ) -> Option<(GpuTempSensorBackend, f32)> { @@ -1081,7 +1381,15 @@ fn amd_sensor_candidates(gpu_index: u32) -> Vec { ] } +/// NVIDIA's own tool, which ships with the driver on every platform NVIDIA +/// supports, and which reports the board's draw as well as its temperature. +/// +/// The power query is the reason Eco and Profit can mean anything on an NVIDIA +/// card: without a per-candidate watt figure every shape is scored on one +/// configured constant, which makes hashes-per-joule and net-EUR affine in the +/// hashrate and ranks all three efficiency modes identically. fn nvidia_sensor(gpu_index: u32) -> GpuTempSensorBackend { + let index = gpu_index.to_string(); command_sensor( "nvidia-smi", format!("nvidia-smi GPU {gpu_index}"), @@ -1089,10 +1397,43 @@ fn nvidia_sensor(gpu_index: u32) -> GpuTempSensorBackend { "--query-gpu=temperature.gpu".into(), "--format=csv,noheader,nounits".into(), "-i".into(), - gpu_index.to_string(), + index.clone(), ], GpuTempParser::Scalar, ) + .with_power_query( + "nvidia-smi", + vec![ + "--query-gpu=power.draw".into(), + "--format=csv,noheader,nounits".into(), + "-i".into(), + index.clone(), + ], + ) + // One row, three quantities, in the order `read_sample` unpacks them. The + // shader clock is the third because of what it is worth on this vendor: a + // Tesla T4 measured at repeat 16 sat at 66 to 67 W against a 70 W cap with + // its SM clock swinging 1140 to 1305 MHz, which is a card riding its POWER + // limit rather than one starved of work. Without the clock, a tuner sees + // only that the bigger launch shape was slower and cannot say why. + .with_sample_query( + "nvidia-smi", + vec![ + "--query-gpu=temperature.gpu,power.draw,clocks.sm".into(), + "--format=csv,noheader,nounits".into(), + "-i".into(), + index.clone(), + ], + ) + .with_power_limit_query( + "nvidia-smi", + vec![ + "--query-gpu=power.limit".into(), + "--format=csv,noheader,nounits".into(), + "-i".into(), + index, + ], + ) } /// The AMD driver's own sensor, on Windows, where no `*-smi` tool exists. @@ -1160,6 +1501,69 @@ pub fn read_gpu_temp_nvidia_smi(gpu_index: u32) -> Option { detect_gpu_temp_sensor("", gpu_index, crate::gpu_arch::GpuVendor::Nvidia).map(|(_, temp)| temp) } +/// Temperature, board draw and shader clock for one GPU, as of one moment. +/// +/// One call, so on the backends that can answer all three at once the three +/// numbers describe the same instant instead of three spawns apart. `None` when +/// nothing on this machine reports this card at all; individual fields are +/// `None` where the card or the tool does not expose that quantity. +pub fn read_gpu_sample( + thermal_file: &str, + gpu_index: u32, + vendor: crate::gpu_arch::GpuVendor, +) -> Option { + let (backend, _) = detect_gpu_temp_sensor(thermal_file, gpu_index, vendor)?; + Some(backend.read_sample()) +} + +/// The board power CAP this GPU is running under, where the vendor's tool +/// reports one. A setting rather than a reading, so callers read it once. +/// +/// It is what turns "this shape drew 66 W" into "this shape sat on a 70 W +/// limit", which is the difference between a card that would go faster with a +/// bigger launch and one that would not. +pub fn read_gpu_power_limit_w( + thermal_file: &str, + gpu_index: u32, + vendor: crate::gpu_arch::GpuVendor, +) -> Option { + let (backend, _) = detect_gpu_temp_sensor(thermal_file, gpu_index, vendor)?; + backend.read_power_limit_w() +} + +/// This card's real board draw right now, or `None` where nothing on this +/// machine measures it. +/// +/// The vendor is required rather than guessed because the answer differs by it: +/// AMD on Windows answers through the display driver, NVIDIA through +/// `nvidia-smi`, and Intel through nothing at all. A caller that has to tell the +/// operator whether a power-aware choice is even possible asks this, and a +/// `None` is a fact about the machine, not a failure to try. +pub fn read_board_power_w_with_gpu( + thermal_file: &str, + gpu_index: u32, + vendor: crate::gpu_arch::GpuVendor, +) -> Option { + let (backend, _) = detect_gpu_temp_sensor(thermal_file, gpu_index, vendor)?; + backend.read_board_power_w() +} + +/// Whether this machine really reports this card's draw. +/// +/// A reading, not a capability: a source can have a way to ask and still get +/// `[N/A]` back, and an AMD adapter can report a temperature from a driver whose +/// board-power slot is unsupported on that model. Answering from the shape of +/// the source rather than from an answer would tell an operator their Eco mode +/// works on a rig where it cannot, which is the failure this whole question +/// exists to prevent. +pub fn board_power_is_measurable( + thermal_file: &str, + gpu_index: u32, + vendor: crate::gpu_arch::GpuVendor, +) -> bool { + read_board_power_w_with_gpu(thermal_file, gpu_index, vendor).is_some() +} + pub fn read_thermal_c(thermal_file: &str) -> Option { read_thermal_c_with_gpu(thermal_file, 0) } @@ -1183,19 +1587,23 @@ pub fn format_efficiency_line( eff: &EfficiencyConf, profile: &str, active_cpu: u32, + measured_gpu_w: Option, ) -> String { - let gpu_w = eff.estimate_gpu_watts(profile); - let cpu_w = active_cpu as f64 * eff.cpu_watts_per_thread; - let watts = gpu_w + cpu_w; + let measured = crate::mining_stats::sensor_board_power_w(measured_gpu_w).is_some(); + let watts = eff.total_watts_now(profile, active_cpu, measured_gpu_w); let hpj = if watts > 0.0 { hashrate / watts / 1000.0 } else { 0.0 }; - let daily_cost = eff.daily_power_cost_eur(profile, active_cpu); + let daily_cost = eff.daily_power_cost_eur_now(profile, active_cpu, measured_gpu_w); + // The console line has to carry the same distinction the panel does, or an + // operator reading the terminal cannot tell a measured 256 W from a + // configured 350 W. `~` is the estimate; a bare number is the card's own. let mut line = format!( - "{} | {:.0}W | {:.1}kH/J | {:.4}HAC/d {:.4}%", + "{} | {}{:.0}W | {:.1}kH/J | {:.4}HAC/d {:.4}%", rates_to_show(hashrate), + if measured { "" } else { "~" }, watts, hpj, hac_per_day, @@ -1230,6 +1638,32 @@ mod tests { assert_eq!(eff.spawn_supervene(0), 0); } + /// A configuration with every knob at a stated value, so a test that changes + /// one of them is changing exactly one thing. + fn base_conf() -> EfficiencyConf { + EfficiencyConf { + mode: EfficiencyMode::Profit, + power_cost_kwh: 0.25, + gpu_watts: 0.0, + cpu_watts_per_thread: 8.0, + hac_price: 0.0, + dynamic_supervene: false, + supervene_min: 0, + supervene_max: 0, + oom_fallback: true, + max_temp_c: 0, + throttle_workgroups: 0, + thermal_file: String::new(), + idle_start_hour: 255, + idle_end_hour: 255, + pause_if_unprofitable: false, + benchmark_seconds: 0, + benchmark_fine_sweep: false, + thermal_gpu_index: 0, + stats_file: String::new(), + } + } + #[test] fn eco_mode_profile_values() { let eff = EfficiencyConf { @@ -1449,6 +1883,302 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(3)); } + #[test] + fn a_measured_draw_beats_the_configured_estimate_in_every_decision() { + let mut eff = base_conf(); + eff.gpu_watts = 350.0; + eff.cpu_watts_per_thread = 8.0; + eff.power_cost_kwh = 0.30; + + let estimated = eff.estimate_gpu_watts("amd_max"); + assert_eq!(eff.gpu_watts_now("amd_max", None), estimated); + assert_eq!(eff.gpu_watts_now("amd_max", Some(256.0)), 256.0); + assert_eq!(eff.total_watts_now("amd_max", 2, Some(256.0)), 256.0 + 16.0); + assert!( + (eff.daily_power_cost_eur_now("amd_max", 2, Some(256.0)) + - (256.0 + 16.0) * 24.0 / 1000.0 * 0.30) + .abs() + < 1e-9 + ); + // An impossible reading is not a measurement, so it falls back to the + // estimate rather than to zero watts and a free rig. + assert_eq!(eff.gpu_watts_now("amd_max", Some(0.0)), estimated); + assert_eq!(eff.gpu_watts_now("amd_max", Some(f32::NAN)), estimated); + // With nothing measured, the new path has to agree exactly with the old + // one, or this change silently moved every unmeasured rig's numbers. + assert_eq!( + eff.daily_power_cost_eur_now("amd_max", 3, None), + eff.daily_power_cost_eur("amd_max", 3) + ); + } + + #[test] + fn the_profit_pause_acts_on_the_measurement_not_on_the_ini() { + // A rig configured at 350 W but really drawing 150 W, earning revenue + // that sits between the two. On the guess it gets paused; on the truth + // it keeps mining, and the difference is the operator's income. + let mut eff = base_conf(); + eff.pause_if_unprofitable = true; + eff.gpu_watts = 350.0; + eff.cpu_watts_per_thread = 0.0; + eff.power_cost_kwh = 1.0; + eff.hac_price = 1.0; + + let estimated_cost = eff.daily_power_cost_eur_now("amd_max", 0, None); + let measured_cost = eff.daily_power_cost_eur_now("amd_max", 0, Some(150.0)); + assert!(measured_cost < estimated_cost); + // Revenue between the two costs, expressed in HAC at a price of 1. + let hac_per_day = (measured_cost + estimated_cost) / 2.0; + + assert!(should_pause_for_profit( + &eff, + hac_per_day, + "amd_max", + 0, + None + )); + assert!(!should_pause_for_profit( + &eff, + hac_per_day, + "amd_max", + 0, + Some(150.0) + )); + } + + #[test] + fn the_console_line_marks_an_estimate_and_leaves_a_measurement_bare() { + let mut eff = base_conf(); + eff.gpu_watts = 350.0; + eff.cpu_watts_per_thread = 0.0; + let guessed = format_efficiency_line(1e6, 0.5, 1.0, &eff, "amd_max", 0, None); + let measured = format_efficiency_line(1e6, 0.5, 1.0, &eff, "amd_max", 0, Some(256.0)); + assert!(guessed.contains("~350W"), "{guessed}"); + assert!(measured.contains("| 256W"), "{measured}"); + assert!(!measured.contains('~'), "{measured}"); + } + + /// The NVIDIA sensor asks the driver for watts, and the AMD `*-smi` ones + /// deliberately do not. + /// + /// This is the whole of defect 1 on the detection side: `Sampler::start` + /// decides `measures_power` from `read_board_power_w`, which returned `None` + /// for every command source, so every NVIDIA card scored every candidate on + /// one configured constant and Eco, Profit and Max ranked identically. + #[test] + fn the_combined_sample_query_and_its_parser_agree_on_the_column_order() { + let GpuTempSensorSource::Command { + sample: Some(sample), + power_limit: Some(limit), + .. + } = nvidia_sensor(1).source + else { + panic!("the nvidia sensor must carry a combined sample query and a power-limit query"); + }; + assert_eq!(sample.program, "nvidia-smi"); + // The order in the query IS the order `read_sample` unpacks: temperature + // first, draw second, clock third. Reorder one and not the other and a + // 66 W draw becomes a 66 C temperature, which is plausible enough to be + // believed. Pinned here rather than left to a comment. + assert!( + sample + .args + .iter() + .any(|a| a == "--query-gpu=temperature.gpu,power.draw,clocks.sm"), + "{:?}", + sample.args + ); + assert!( + sample + .args + .iter() + .any(|a| a == "--format=csv,noheader,nounits"), + "the parser reads bare numbers, so the query must not print units or a header" + ); + assert_eq!( + sample.args.windows(2).find(|w| w[0] == "-i").map(|w| &w[1]), + Some(&"1".to_string()) + ); + assert!(limit.args.iter().any(|a| a == "--query-gpu=power.limit")); + + // A T4's row under load, and the same row on a card that exposes no + // power sensor: `[N/A]` does not parse, so that field alone is absent + // and the other two survive. + let row = parse_csv_row("63, 66.51, 1305\n"); + assert_eq!(row.len(), 3); + assert_eq!(row[0], Some(63.0)); + assert_eq!(row[1], Some(66.51)); + assert_eq!(row[2], Some(1305.0)); + + let partial = parse_csv_row("63, [N/A], 1305"); + assert_eq!(partial[0], Some(63.0)); + assert_eq!(partial[1], None); + assert_eq!(partial[2], Some(1305.0)); + + // Zero is what an unsupported slot reports, and no board draws zero + // watts while hashing, so it is absence rather than free electricity. + assert_eq!(plausible_clock_mhz(0.0), None); + assert_eq!(plausible_clock_mhz(1305.0), Some(1305.0)); + assert_eq!(plausible_clock_mhz(f32::NAN), None); + } + + #[test] + fn a_backend_with_no_combined_query_still_samples_what_it_has() { + // A hwmon temperature file holds a temperature and nothing else. The + // fallback must report exactly that: no invented clock, no invented + // watts. The tuner treats an absent signal as "not judged" and an + // invented one as evidence, so the difference is not cosmetic. + let missing = GpuTempSensorBackend { + label: "thermal file".to_string(), + source: GpuTempSensorSource::File(PathBuf::from("/definitely/not/a/hwmon/temp1_input")), + }; + let sample = missing.read_sample(); + assert_eq!(sample.temp_c, None); + assert_eq!(sample.watts, None); + assert_eq!(sample.clock_mhz, None); + assert_eq!(missing.read_power_limit_w(), None); + + // The AMD `*-smi` sources were never given a power or clock query, so + // they must not answer with either. + for sensor in amd_sensor_candidates(0) { + assert!( + matches!( + sensor.source, + GpuTempSensorSource::Command { sample: None, .. } + ), + "{} has no combined query and must not pretend to one", + sensor.label() + ); + assert_eq!(sensor.read_power_limit_w(), None); + } + } + + #[test] + fn only_the_nvidia_command_sensor_carries_a_power_query() { + for index in [0, 3] { + assert!( + matches!( + nvidia_sensor(index).source, + GpuTempSensorSource::Command { power: Some(_), .. } + ), + "nvidia-smi reports power.draw and ships with the driver" + ); + } + // The AMD `*-smi` sources are invoked with temperature-only queries, so + // they answer `None` without spawning anything rather than guessing a + // watt figure out of a query nobody made. + for sensor in amd_sensor_candidates(0) { + assert!( + matches!( + sensor.source, + GpuTempSensorSource::Command { power: None, .. } + ), + "{} was not asked for power, so it must not answer with any", + sensor.label() + ); + assert_eq!(sensor.read_board_power_w(), None); + } + let file = GpuTempSensorBackend { + label: "thermal file".to_string(), + source: GpuTempSensorSource::File(PathBuf::from("/sys/class/hwmon/hwmon0/temp1_input")), + }; + assert_eq!( + file.read_board_power_w(), + None, + "a hwmon temperature file holds no power at all" + ); + } + + /// The power query names the GPU it is asking about, and asks only for power. + /// + /// A query that dropped `-i ` would report the first card on a + /// multi-GPU rig whatever `thermal_gpu_index` said, which is a wrong number + /// rather than a missing one. + #[test] + fn the_nvidia_power_query_is_power_only_and_names_its_gpu() { + let GpuTempSensorSource::Command { + power: Some(power), .. + } = nvidia_sensor(2).source + else { + panic!("the nvidia sensor must carry a power query"); + }; + assert_eq!(power.program, "nvidia-smi"); + assert!(power.args.iter().any(|a| a == "--query-gpu=power.draw")); + assert!( + power + .args + .iter() + .any(|a| a == "--format=csv,noheader,nounits") + ); + assert_eq!( + power.args.windows(2).find(|w| w[0] == "-i").map(|w| &w[1]), + Some(&"2".to_string()) + ); + assert!( + !power.args.iter().any(|a| a.contains("temperature")), + "one query, one quantity: {:?}", + power.args + ); + } + + /// Absent means absent on the NVIDIA path too. + /// + /// `nvidia-smi` prints `[N/A]` for power on the parts and virtualised setups + /// that do not expose the sensor. That must become the configured estimate + /// labelled as an estimate, never a zero-watt card mining for free. + #[test] + fn nvidia_power_output_is_a_measurement_or_nothing() { + assert_eq!(parse_board_power_output("142.31\n"), Some(142.31)); + assert_eq!(parse_board_power_output(" 46.05 "), Some(46.05)); + assert_eq!(parse_board_power_output("\n\n311\n"), Some(311.0)); + // Every way it can be absent rather than low. + assert_eq!(parse_board_power_output("[N/A]\n"), None); + assert_eq!(parse_board_power_output("[Not Supported]"), None); + assert_eq!(parse_board_power_output(""), None); + assert_eq!(parse_board_power_output("0.00\n"), None); + assert_eq!(parse_board_power_output("-3.5"), None); + assert_eq!(parse_board_power_output("1000.0"), None); + assert_eq!(parse_board_power_output("nan"), None); + // And it must not fall through to the temperature parser's habits: a + // labelled line is not a bare number and is refused. + assert_eq!(parse_board_power_output("power.draw [W] : 142.31"), None); + } + + /// The ADL validator and the nvidia-smi validator are the same rule. + #[test] + fn one_plausibility_window_judges_every_power_source() { + assert_eq!(plausible_board_power_w(46.0), Some(46.0)); + assert_eq!(plausible_board_power_w(256.0), Some(256.0)); + assert_eq!(plausible_board_power_w(0.0), None); + assert_eq!(plausible_board_power_w(-1.0), None); + assert_eq!(plausible_board_power_w(1000.0), None); + assert_eq!(plausible_board_power_w(f32::NAN), None); + assert_eq!(plausible_board_power_w(f32::INFINITY), None); + #[cfg(windows)] + for value in [46.0f32, 0.0, 1000.0, f32::NAN, 749.0] { + assert_eq!( + crate::gpu_temp_adl::plausible_board_power_w(value), + plausible_board_power_w(value), + "the two power paths must not drift apart at {value}" + ); + } + } + + /// Intel measures nothing, and the code says so instead of pretending. + #[test] + fn intel_has_neither_a_temperature_nor_a_power_source() { + assert!(detect_gpu_temp_sensor("", 0, crate::gpu_arch::GpuVendor::Intel).is_none()); + assert!(!board_power_is_measurable( + "", + 0, + crate::gpu_arch::GpuVendor::Intel + )); + assert_eq!( + read_board_power_w_with_gpu("", 0, crate::gpu_arch::GpuVendor::Intel), + None + ); + } + #[test] fn max_mode_requires_at_least_profit_tier() { assert_eq!(min_profile_tier_for_mode(EfficiencyMode::Max), 2); @@ -1461,17 +2191,24 @@ pub use crate::mining_stats::{ MiningStatsSnapshot, build_diamond_mining_stats, build_mining_stats, write_mining_stats, }; +/// Whether electricity is costing more than the block reward is worth. +/// +/// `measured_gpu_w` is the rig's real board draw where a card reported one. It +/// is the reason this takes an argument at all: pausing a profitable rig, or +/// running an unprofitable one, on the strength of a `gpu_watts` line an +/// operator typed once is the single most expensive thing a guess can do here. pub fn should_pause_for_profit( eff: &EfficiencyConf, hac_per_day: f64, profile: &str, active_cpu: u32, + measured_gpu_w: Option, ) -> bool { if !eff.pause_if_unprofitable || eff.hac_price <= 0.0 { return false; } let revenue = hac_per_day * eff.hac_price; - revenue < eff.daily_power_cost_eur(profile, active_cpu) + revenue < eff.daily_power_cost_eur_now(profile, active_cpu, measured_gpu_w) } /// HACD profit pause: when `hac_price` is set, treat it as minimum daily EUR revenue diff --git a/app/src/gpu_arch.rs b/app/src/gpu_arch.rs index cde9e42e..79a0c604 100644 --- a/app/src/gpu_arch.rs +++ b/app/src/gpu_arch.rs @@ -205,7 +205,7 @@ impl ArchLimits { } } - /// RDNA4 / RX 9070 XT — validated conservative launch and OOM treatment. + /// RDNA4 / RX 9070 XT, validated conservative launch and OOM treatment. pub fn is_experimental(&self) -> bool { !self.oom_ramp_to_base && self.oom_floor_wg == 32 } @@ -236,8 +236,32 @@ impl ArchLimits { } /// Panel preset slug → max unit_size (live gfx1201/RDNA4 stable path). + /// + /// 192 for gfx1201, and the number is measured rather than chosen. + /// + /// The kernel is latency bound on this card, not busy: 256 VGPRs, spill to + /// scratch, tables read from `__constant`, LDS unused. It is starved of work + /// in flight, and `unit_size` feeds it far more cheaply than `work_groups` + /// does. At a matched batch of 3,145,728 nonces, 64 groups x 192 units gives + /// 28.80 MH/s against 25.85 for 256 groups x 48, so the same nonces arranged + /// the other way are worth about 11% less. + /// + /// Against the shipping 48 x 256 x 48 this is 19.13 -> 28.80 MH/s, +50.4% on + /// a 0.5% noise floor, bracketed by opening and closing controls that agreed + /// to 0.26%. Byte equivalence was proven at the new shape before the number + /// was believed: 1,638,950 hashes compared against the CPU oracle, zero + /// mismatches, every one of the 16 algorithms about 20,600 rounds. + /// + /// Note the shipped 48 was pathological. 32 CUs host 2 groups each, so 48 + /// and 96 leave a half empty scheduling tail while 64, 128 and 192 divide + /// evenly, which is why the sweep dips at 96 and not elsewhere. + /// + /// `is_experimental()` is true only for gfx1201, and `workgroups_cap` holds + /// groups at 64 there, so the largest batch this permits is 64 x 256 x 192 = + /// 113 MB of device state. It cannot reach the multi gigabyte allocations a + /// raised work-group cap would. pub fn max_unit_size(&self) -> u32 { - if self.is_experimental() { 64 } else { 128 } + if self.is_experimental() { 192 } else { 128 } } pub fn panel_max_unit_size(panel_slug: &str) -> u32 { @@ -273,6 +297,50 @@ pub fn panel_min_work_groups(gpu_slug: &str) -> u32 { ArchLimits::for_panel_slug(gpu_slug).panel_min_wg } +/// Every card the panel can be set to, as (slug, shipped base profile, VRAM GB). +/// +/// The panel owns the labels and the watts; this is the part the tuning code +/// needs, kept here so that limits, panel resolution and the auto-tuner can all +/// be tested over the same list instead of three lists that drift. The panel's +/// own preset table is asserted equal to this one, so adding a card there +/// without adding it here fails a test rather than shipping an untested card. +/// +/// "none" is deliberately absent: it is the no-GPU entry and has no limits. +pub const PANEL_GPU_PRESETS: [(&str, &str, u8); 19] = [ + ("rx6600", "amd_balanced", 8), + ("rx7600", "amd_balanced", 8), + ("rx6700xt", "amd_performance", 12), + ("rx6800xt", "amd_performance", 16), + ("rx7900xt", "amd_performance", 20), + ("rx7900xtx", "amd_max", 24), + ("rx9070xt", "amd_balanced", 16), + ("rtx3060", "nvidia_balanced", 8), + ("rtx4060", "nvidia_balanced", 8), + ("rtx3070", "nvidia_profit", 12), + ("rtx4070", "nvidia_performance", 12), + ("rtx4090", "nvidia_max", 24), + ("rtx5060", "nvidia_balanced", 8), + ("rtx5070", "nvidia_performance", 12), + ("rtx5080", "nvidia_performance", 16), + ("rtx5090", "nvidia_max", 32), + ("arc_a380", "intel_balanced", 6), + ("arc_a750", "intel_performance", 8), + ("arc_a770", "intel_performance", 16), +]; + +/// Every architecture slug `arch_slug()` can hand `ArchLimits::for_slug`, one +/// per family the detection code names, plus the generic fallback shape. +/// +/// Used by the tests that have to prove a change was confined to one card. +pub const KNOWN_ARCH_SLUGS: [&str; 22] = [ + // AMD, as the OpenCL driver reports them. + "gfx1201", "gfx1200", "gfx1151", "gfx1100", "gfx1101", "gfx1102", "gfx1030", "gfx1031", + "gfx1032", "gfx1010", "gfx906", "gfx900", // Intel Arc, from the model table. + "arca770", "arca750", "arca580", "arca380", "arca310", + // NVIDIA and AMD board names, from the token table. + "rtx5090", "rtx4090", "rtx3060", "rx7900", "rx6600", +]; + /// Sanitize device name for use in binary cache filenames. pub fn safe_device_filename(device_name: &str) -> String { device_name @@ -387,4 +455,106 @@ mod tests { assert!(!ArchLimits::needs_amd_queue_finish("gfx1100", false)); assert!(ArchLimits::needs_amd_queue_finish("gfx1100", true)); } + + /// The raised unit_size ceiling was measured on one card and must stay on it. + /// + /// 192 is the RX 9070 XT's measured optimum. It was never measured anywhere + /// else, and on a card with a different register file or a different LDS + /// budget it is a guess that costs VRAM and batch latency. Every other slug + /// keeps 128, which is what every build before this one used. + #[test] + fn the_raised_unit_size_ceiling_is_gfx1201_only() { + assert_eq!(ArchLimits::for_slug("gfx1201").max_unit_size(), 192); + assert_eq!(ArchLimits::panel_max_unit_size("rx9070xt"), 192); + + for slug in KNOWN_ARCH_SLUGS { + let expected = if slug == "gfx1201" { 192 } else { 128 }; + assert_eq!( + ArchLimits::for_slug(slug).max_unit_size(), + expected, + "arch slug {slug}" + ); + } + for (slug, _, _) in PANEL_GPU_PRESETS { + let expected = if slug == "rx9070xt" { 192 } else { 128 }; + assert_eq!( + ArchLimits::panel_max_unit_size(slug), + expected, + "panel slug {slug}" + ); + } + // An unrecognised card is not experimental either. + assert_eq!(ArchLimits::panel_max_unit_size("some_future_card"), 128); + assert_eq!(ArchLimits::for_slug("").max_unit_size(), 128); + } + + /// Everything `is_experimental()` gates is likewise one card's: the 32 work + /// group floor, the 64 work group cap, the refusal to ramp back to base + /// after an OOM, and the queue drain after every batch. + #[test] + fn the_experimental_limits_are_gfx1201_only() { + for slug in KNOWN_ARCH_SLUGS { + let limits = ArchLimits::for_slug(slug); + if slug == "gfx1201" { + assert!(limits.is_experimental(), "{slug}"); + assert_eq!(limits.panel_min_wg, 32); + assert_eq!(limits.workgroups_cap(4096, 1), 64); + assert!(ArchLimits::needs_amd_queue_finish(slug, false)); + } else { + assert!(!limits.is_experimental(), "{slug}"); + assert_eq!(limits.panel_min_wg, 256, "{slug}"); + assert_eq!(limits.oom_floor_wg, 512, "{slug}"); + assert!(limits.oom_ramp_to_base, "{slug}"); + assert_eq!(limits.workgroups_cap(4096, 1), 4096, "{slug}"); + assert!(!ArchLimits::needs_amd_queue_finish(slug, false), "{slug}"); + } + } + for (slug, _, _) in PANEL_GPU_PRESETS { + assert_eq!( + ArchLimits::for_panel_slug(slug).is_experimental(), + slug == "rx9070xt", + "panel slug {slug}" + ); + } + } + + /// `vram_gb` is a live input only on the fallback path. Every named preset + /// carries its own hard ceiling, so a wrong VRAM reading cannot move it. + #[test] + fn vram_moves_the_ceiling_only_for_cards_with_no_preset() { + for (slug, _, vram) in PANEL_GPU_PRESETS { + let at_its_own = ArchLimits::panel_max_work_groups(slug, vram); + for other in [0u8, 2, 4, 6, 8, 12, 16, 24, 32, 48, 255] { + assert_eq!( + ArchLimits::panel_max_work_groups(slug, other), + at_its_own, + "{slug} moved when told it had {other} GB" + ); + } + } + } + + /// The fallback path is the only one that reads VRAM, and it has to + /// discriminate: a 4 GB card and a 24 GB card must not get one ceiling. + #[test] + fn the_vram_fallback_separates_a_small_card_from_a_large_one() { + let at = |gb: u8| ArchLimits::panel_max_work_groups("some_future_card", gb); + assert!( + at(4) < at(24), + "4 GB and 24 GB both got {} work groups", + at(4) + ); + assert_eq!(at(4), 1024); + assert_eq!(at(24), 3072); + assert_eq!(at(32), 4096); + // Monotone, so more memory is never punished. + let mut previous = 0; + for gb in 0u8..=64 { + let now = at(gb); + assert!(now >= previous, "{gb} GB dropped to {now} from {previous}"); + previous = now; + } + // And the spread is real rather than cosmetic. + assert!(at(64) >= at(4) * 4); + } } diff --git a/app/src/gpu_oom.rs b/app/src/gpu_oom.rs index 80cde465..021ee2c6 100644 --- a/app/src/gpu_oom.rs +++ b/app/src/gpu_oom.rs @@ -85,7 +85,9 @@ impl GpuOomState { if next < cur { wlogerr!( "[efficiency] OpenCL error - reducing work_groups {} -> {} (floor={})", - cur, next, floor + cur, + next, + floor ); self.effective_workgroups.store(next, Relaxed); self.oom_reduced.store(true, Relaxed); @@ -153,7 +155,9 @@ impl GpuOomState { } wlogln!( "[efficiency] GPU stable for {} batches - raising work_groups {} -> {}", - n, cur, next + n, + cur, + next ); } } diff --git a/app/src/gpu_temp_adl.rs b/app/src/gpu_temp_adl.rs index 6ea294b2..c98f5810 100644 --- a/app/src/gpu_temp_adl.rs +++ b/app/src/gpu_temp_adl.rs @@ -22,7 +22,8 @@ //! ADL2_Main_Control_Create ~10 ms, once //! ADL2_New_QueryPMLogData_Get 0.2 to 1.1 ms per read //! sensors: edge 60 C, memory 68 C, hotspot 84 C, fan 1032 rpm, -//! activity 99%, gfx clock 3302 MHz, gfx voltage 1123 mV +//! activity 99%, gfx clock 3302 MHz, gfx voltage 1123 mV, +//! board power 256 W //! ADL2_OverdriveN_Temperature_Get ADL_ERR_NOT_SUPPORTED (-8) //! ADL2_Overdrive6_Temperature_Get ADL_ERR_NOT_SUPPORTED (-8) //! @@ -38,6 +39,31 @@ //! temperature indices next to them trustworthy. A sensor whose `supported` //! flag is clear, or whose value is outside a plausible range, is dropped by the //! caller rather than reported. +//! +//! Board power (index 73) was pinned down the same way, by dumping every +//! non-zero slot at three load levels on this card: +//! +//! state idx 19 idx 1 idx 73 idx 58 +//! idle desktop 2-7% 58-137MHz 46 W 5 +//! x16rs_gate, 3 work groups 99% 3383 MHz 120 W 5 +//! x16rs_gate, 48 work groups 99% 3312 MHz 256 W 5 +//! +//! Index 73 is the only slot that separates those three states in watts, and its +//! neighbours in the enum land where the enum says: index 40 reads 4 and index +//! 41 reads 16 on a card in a PCIe gen 4 x16 slot, which is `ADL_PMLOG_BUS_SPEED` +//! and `ADL_PMLOG_BUS_LANES` exactly. Index 58 was the other candidate and it is +//! flat at 5 through all three states, so it is the throttler percentage the +//! enum says it is, not a power. The numbers are whole watts, not milliwatts and +//! not hundredths: 256 at full tilt is what a 304 W-rated RX 9070 XT actually +//! draws on this workload, and a hundredths reading would have been 2.56 W. +//! +//! What index 73 measures is TOTAL BOARD power, the figure on the electricity +//! bill, not the GPU die alone. `ADL_PMLOG_ASIC_POWER` (23) and +//! `ADL_PMLOG_GFX_POWER` (30) are the die-only figures and neither is supported +//! on this card and driver: both read `supported = 0`. Nothing here silently +//! falls back to them, because die-only power is tens of watts below board power +//! and quoting one as the other would understate the operator's cost. Where +//! board power is absent, this module reports no power at all. use std::ffi::{CString, c_char, c_int, c_void}; use std::sync::{Mutex, OnceLock}; @@ -74,6 +100,17 @@ const PMLOG_TEMPERATURE_EDGE: usize = 8; const PMLOG_TEMPERATURE_MEM: usize = 9; const PMLOG_TEMPERATURE_HOTSPOT: usize = 27; +/// `ADL_PMLOG_BOARD_POWER`: whole watts drawn by the whole graphics board, the +/// 12V rails and the memory and the VRM losses included. Deliberately not +/// `ADL_PMLOG_ASIC_POWER` (23) or `ADL_PMLOG_GFX_POWER` (30), which are the die +/// alone and read tens of watts lower. See the module header. +const PMLOG_BOARD_POWER: usize = 73; + +/// `ADL_PMLOG_CLK_GFXCLK`: shader clock in MHz. Confirmed in the same dump the +/// module header quotes: 58-137 MHz on an idle desktop against 3383 MHz under +/// the miner, which is the only slot that moves that way. +const PMLOG_GFX_CLOCK: usize = 1; + /// `AdapterInfo` from `adl_structures.h`. The last five fields are Windows-only /// in the header and this file is Windows-only, so all of them are present. The /// struct is passed by us and filled by ADL, so its size must match exactly: @@ -153,6 +190,27 @@ fn plausible_temp(value: c_int) -> Option { (value > 0.0 && value < 120.0).then_some(value) } +/// The widest window a graphics board's total draw can honestly fall in. +/// +/// One definition, in `efficiency`, shared with the `nvidia-smi` power path so +/// that a driver reading and a command reading are judged by the same rule and +/// cannot drift apart. Kept here as a name because this module's callers are +/// about ADL slots, not about the panel's economics. +pub(crate) fn plausible_board_power_w(value: f32) -> Option { + crate::efficiency::plausible_board_power_w(value) +} + +/// A shader clock a GPU can really be running at. +/// +/// The floor is 1 MHz rather than something comfortable because this card idles +/// at 58 MHz and a deep-idle reading is still a reading; only an exact zero, +/// which is what an unsupported slot holds, is refused. The ceiling is far above +/// any shipping part, so it excludes a slot holding kHz or a different quantity +/// entirely rather than excluding a fast card. +pub(crate) fn plausible_gfx_clock_mhz(value: f32) -> Option { + (value.is_finite() && value > 0.0 && value < 10_000.0).then_some(value) +} + impl Adl { fn load() -> Option> { // SAFETY: every pointer below is either checked for null before use or @@ -216,7 +274,11 @@ impl Adl { } } - fn temperature(&self, adapter_index: i32) -> Option { + /// One PMLog query. Every reader below goes through this, so a caller that + /// wants several sensors reads them from ONE driver call and therefore from + /// one instant, instead of stitching together readings taken milliseconds + /// apart while the clocks move. + fn query(&self, adapter_index: i32) -> Option { // SAFETY: the output struct is fully owned here and ADL only writes into // it. A bad adapter index is refused by the library with a non-zero // return (measured: -5 for an index that does not exist, -8 for an @@ -226,17 +288,62 @@ impl Adl { if (self.query_pmlog)(self.context, adapter_index as c_int, &mut out) != ADL_OK { return None; } - [ - PMLOG_TEMPERATURE_EDGE, - PMLOG_TEMPERATURE_MEM, - PMLOG_TEMPERATURE_HOTSPOT, - ] - .into_iter() - .filter(|index| out.sensors[*index].supported != 0) - .filter_map(|index| plausible_temp(out.sensors[index].value)) - .reduce(f32::max) + Some(out) } } + + fn temperature(&self, adapter_index: i32) -> Option { + self.query(adapter_index)?.temperature() + } + + /// Total board power in watts, or `None` where this card does not measure it. + /// + /// One sensor, not a maximum over several: unlike temperature, where the + /// hottest of the three is the one that matters, there is exactly one number + /// that is the board's draw, and reducing over candidates would silently + /// promote a die-only figure whenever the board figure went missing. + fn board_power_w(&self, adapter_index: i32) -> Option { + self.query(adapter_index)?.board_power_w() + } +} + +impl PmLogDataOutput { + fn slot(&self, index: usize) -> Option { + let slot = self.sensors[index]; + (slot.supported != 0).then_some(slot.value) + } + + fn temperature(&self) -> Option { + [ + PMLOG_TEMPERATURE_EDGE, + PMLOG_TEMPERATURE_MEM, + PMLOG_TEMPERATURE_HOTSPOT, + ] + .into_iter() + .filter_map(|index| self.slot(index)) + .filter_map(plausible_temp) + .reduce(f32::max) + } + + fn board_power_w(&self) -> Option { + plausible_board_power_w(self.slot(PMLOG_BOARD_POWER)? as f32) + } + + fn gfx_clock_mhz(&self) -> Option { + plausible_gfx_clock_mhz(self.slot(PMLOG_GFX_CLOCK)? as f32) + } +} + +/// One instant of a card's telemetry, all of it from a single driver query. +/// +/// Every field is optional on purpose. A slot this driver does not support is +/// absent, never zero: a zero clock or a zero draw would be read downstream as a +/// measurement of an idle card rather than as the absence of a sensor. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub struct AdlSample { + pub temp_c: Option, + pub board_power_w: Option, + pub gfx_clock_mhz: Option, } fn adl() -> Option<&'static Mutex> { @@ -286,6 +393,35 @@ pub fn temperature_c(adapter_index: i32) -> Option { adl.temperature(adapter_index) } +/// Total board power in watts for one adapter that `reporting_gpus` bound to. +/// +/// `None` on a card whose driver does not publish it, which is the whole point: +/// a caller that gets nothing here has to say so and fall back to its configured +/// estimate, rather than publish a zero that reads as a card drawing no power. +pub fn board_power_w(adapter_index: i32) -> Option { + let adl = adl()?; + let adl = adl.lock().unwrap_or_else(|error| error.into_inner()); + adl.board_power_w(adapter_index) +} + +/// Temperature, board power and shader clock for one adapter, from a single +/// driver query. +/// +/// The auto-tuner needs all three at once to decide whether the card has settled +/// into a steady state. Reading them one call at a time would cost three driver +/// round trips per sample and would compare a temperature to a clock taken at a +/// different instant, which is exactly the thing a settling test must not do. +pub fn sample(adapter_index: i32) -> Option { + let adl = adl()?; + let adl = adl.lock().unwrap_or_else(|error| error.into_inner()); + let out = adl.query(adapter_index)?; + Some(AdlSample { + temp_c: out.temperature(), + board_power_w: out.board_power_w(), + gfx_clock_mhz: out.gfx_clock_mhz(), + }) +} + /// Why there is no ADL temperature, in words an operator can act on. /// /// Only called on the failure path, and it says what was actually observed @@ -354,6 +490,47 @@ mod tests { assert_eq!(temperature_c(-1), None); } + #[test] + fn an_adapter_index_that_cannot_exist_yields_no_power() { + // Same rule as the temperature above, and it matters more here: the zero + // sitting in an unread output buffer is a perfectly formatted watt value + // that would go straight into an operator's cost figure. + assert_eq!(board_power_w(i32::MAX), None); + assert_eq!(board_power_w(-1), None); + } + + #[test] + fn a_card_that_answers_at_all_answers_with_a_board_power_in_range() { + // On a machine with no AMD driver this loop is empty and the test is + // trivially true, which is the same shape the temperature test uses. + // On the AMD box it is the real assertion: whatever the card reports has + // to survive the plausibility window rather than be published raw. + for gpu in reporting_gpus() { + if let Some(watts) = board_power_w(gpu.adapter_index) { + assert!( + watts > 0.0 && watts < 1000.0, + "{} reported {watts} W, which is not a board draw", + gpu.name + ); + } + } + } + + #[test] + fn implausible_sensor_values_are_not_board_power() { + // Zero is what an unsupported slot holds, so it must never become a + // reading; a board drawing nothing is not a state a running card is in. + assert_eq!(plausible_board_power_w(0.0), None); + assert_eq!(plausible_board_power_w(-5.0), None); + assert_eq!(plausible_board_power_w(1000.0), None); + assert_eq!(plausible_board_power_w(f32::NAN), None); + assert_eq!(plausible_board_power_w(f32::INFINITY), None); + // The three states this card was actually measured in. + assert_eq!(plausible_board_power_w(46.0), Some(46.0)); + assert_eq!(plausible_board_power_w(120.0), Some(120.0)); + assert_eq!(plausible_board_power_w(256.0), Some(256.0)); + } + #[test] fn implausible_sensor_values_are_not_temperatures() { assert_eq!(plausible_temp(0), None); @@ -361,4 +538,48 @@ mod tests { assert_eq!(plausible_temp(120), None); assert_eq!(plausible_temp(60), Some(60.0)); } + + #[test] + fn implausible_sensor_values_are_not_clocks() { + // Zero is the unsupported slot again. A deep-idle 58 MHz is a real + // reading on this card and must survive, or the settling test would see + // an idle card as a card with no clock sensor at all. + assert_eq!(plausible_gfx_clock_mhz(0.0), None); + assert_eq!(plausible_gfx_clock_mhz(-1.0), None); + assert_eq!(plausible_gfx_clock_mhz(10_000.0), None); + assert_eq!(plausible_gfx_clock_mhz(f32::NAN), None); + assert_eq!(plausible_gfx_clock_mhz(58.0), Some(58.0)); + assert_eq!(plausible_gfx_clock_mhz(3383.0), Some(3383.0)); + } + + #[test] + fn one_sample_agrees_with_the_single_sensor_readers() { + // The sampler is a second path to the same slots. If it ever disagreed + // with the readers the rest of the miner publishes, the auto-tuner would + // be settling on numbers nobody else can see. + assert_eq!(sample(i32::MAX), None); + for gpu in reporting_gpus() { + let Some(sample) = sample(gpu.adapter_index) else { + panic!("{} answered reporting_gpus but not sample", gpu.name); + }; + assert!( + sample.temp_c.is_some(), + "{} is in reporting_gpus, so it has a temperature", + gpu.name + ); + // Not equality: these are two queries taken moments apart and the + // card is live. Same sensor, same order of magnitude, same units. + if let (Some(one), Some(two)) = (sample.board_power_w, board_power_w(gpu.adapter_index)) + { + assert!( + (one - two).abs() < 200.0, + "{} reported {one} W then {two} W from the same slot", + gpu.name + ); + } + if let Some(clock) = sample.gfx_clock_mhz { + assert!(clock > 0.0 && clock < 10_000.0); + } + } + } } diff --git a/app/src/hash_util.rs b/app/src/hash_util.rs index a9b02f4a..f122f396 100644 --- a/app/src/hash_util.rs +++ b/app/src/hash_util.rs @@ -73,9 +73,285 @@ pub fn diamond_better(dst: &[u8], src: &[u8]) -> bool { } } +/// Step 1 of `x16rs::check_diamond_difficulty`, with the per-number terms hoisted +/// out of the nonce loop. +/// +/// COPY NOTICE. The two fields and `passes` below are a VERBATIM copy of the +/// "check step 1" block of `x16rs::check_diamond_difficulty` +/// (x16rs/src/diamond.rs:58) -- same MODIFFBITS table, same `number / 42000`, +/// same `255 - ((number / 65536) as u8)`, same `>=` / `>` comparisons, same +/// iteration order. It is duplicated here, beside `diamond_name_is_valid`, so +/// that x16rs/ stays byte-for-byte untouched and therefore the full node stays +/// untouched. IF `check_diamond_difficulty` EVER CHANGES, THIS MUST CHANGE WITH +/// IT: `the_gate_is_a_verbatim_copy_of_step_one` and +/// `a_failing_gate_forbids_every_possible_x16rs_hash` below are the tripwires, +/// and they fail loudly rather than silently mining a wrong difficulty. +/// +/// The whole point: step 1 reads ONLY `sha3hx`. It never looks at the x16rs +/// hash. So when it fails, `check_diamond_difficulty` returns false for EVERY +/// possible x16rs hash, and the expensive `x16rs_hash` rounds for that nonce +/// cannot produce a diamond no matter what they compute. +pub struct DiamondSha3Gate { + shnumlp: usize, + shmaxit: u8, +} + +impl DiamondSha3Gate { + /// Both terms depend only on the diamond number, which is constant for a + /// whole mining round, so they are computed once per round instead of once + /// per nonce. + pub fn for_number(number: u32) -> Self { + Self { + shnumlp: number as usize / 42000, // 32step max to 64 years + shmaxit: 255 - ((number / 65536) as u8), + } + } + + /// False means: no x16rs hash whatsoever can turn this sha3 into a diamond. + pub fn passes(&self, sha3hx: &[u8; x16rs::H32S]) -> bool { + const MODIFFBITS: [u8; x16rs::H32S] = [ + // difficulty requirements + 128, 132, 136, 140, 144, 148, 152, 156, // step +4 + 160, 164, 168, 172, 176, 180, 184, 188, 192, 196, 200, 204, 208, 212, 216, 220, 224, + 228, 232, 236, 240, 244, 248, 252, + ]; + for i in 0..x16rs::H32S { + if i < self.shnumlp && sha3hx[i] >= MODIFFBITS[i] { + return false; // fail + } + if sha3hx[i] > self.shmaxit { + return false; // fail + } + } + true + } +} + #[cfg(test)] mod tests { use super::*; + use x16rs::{H32S, check_diamond_difficulty, check_diamond_hash_result, diamond_hash}; + + /// Deterministic splitmix64. A fixed seed is deliberate: a failure here is a + /// consensus failure, and it must reproduce exactly on the next run. + struct Rng(u64); + + impl Rng { + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn byte(&mut self) -> u8 { + (self.next_u64() >> 24) as u8 + } + + fn hash(&mut self) -> [u8; H32S] { + let mut h = [0u8; H32S]; + for b in h.iter_mut() { + *b = self.byte(); + } + h + } + + /// A hash biased towards small leading bytes, so that the gate actually + /// PASSES sometimes at high diamond numbers. A uniform draw fails step 1 + /// on essentially every attempt there, and the implication would then + /// hold vacuously. The prefix length sweeps 0..=32 so that both sides of + /// the gate are exercised at every number in NUMBERS. + fn easy_hash(&mut self) -> [u8; H32S] { + let mut h = self.hash(); + let lead = (self.next_u64() % (H32S as u64 + 1)) as usize; + for b in h.iter_mut().take(lead) { + *b = self.byte() % 64; // below MODIFFBITS[0]=128 and below every shmaxit + } + h + } + } + + /// Diamond numbers spanning every regime of step 1: before the 42000 loop + /// starts, the measured 133700 point, the sparse high numbers, and the + /// 65536 boundaries where `shmaxit` steps down. + const NUMBERS: [u32; 14] = [ + 0, 1, 41_999, 42_000, 65_535, 65_536, 65_537, 84_000, 131_072, 133_700, 210_000, 300_000, + 1_000_000, 1_344_000, + ]; + + /// THE EQUIVALENCE PROOF, and the reason the prefilter is allowed to exist. + /// + /// It quantifies over the x16rs hash instead of drawing one: for each + /// (number, sha3) whose gate fails, EVERY x16rs hash in a set that includes + /// the extreme values (all zero -- the strongest hash the algorithm can + /// produce, which satisfies step 2 at any difficulty) must still be + /// rejected. That is strictly stronger than sampling, and it is why + /// skipping the x16rs rounds cannot lose a diamond. + #[test] + fn a_failing_gate_forbids_every_possible_x16rs_hash() { + let mut rng = Rng(0x0DDB_A11C_0FFE_E123); + let mut failed = 0usize; + let mut passed = 0usize; + for number in NUMBERS { + let gate = DiamondSha3Gate::for_number(number); + for k in 0..400 { + let sha3hx = if k % 2 == 0 { + rng.hash() + } else { + rng.easy_hash() + }; + if gate.passes(&sha3hx) { + passed += 1; + continue; + } + failed += 1; + // Arbitrary x16rs hashes, including the two extremes and the + // all-zero hash that clears step 2 at any difficulty. + let mut arbitrary = vec![[0u8; H32S], [255u8; H32S], [1u8; H32S]]; + let mut alt = [0u8; H32S]; + alt[H32S - 1] = 1; + arbitrary.push(alt); + for _ in 0..8 { + arbitrary.push(rng.hash()); + } + for x16rshx in &arbitrary { + assert!( + !check_diamond_difficulty(number, &sha3hx, x16rshx), + "gate rejected number={} sha3={:?} but check_diamond_difficulty \ + accepted it with x16rs={:?}", + number, + sha3hx, + x16rshx + ); + } + } + } + // Non-vacuity: the implication is only interesting if both sides of the + // gate were actually exercised. + assert!(failed > 100, "gate never rejected anything ({failed})"); + assert!(passed > 100, "gate never accepted anything ({passed})"); + } + + /// The other half: the gate must not reject anything the original accepts, + /// or the miner would silently drop real diamonds. `check_diamond_difficulty + /// == true` implies `gate == true`, which is the contrapositive of the test + /// above but is asserted directly against the real function so a typo in one + /// of the two MODIFFBITS tables cannot hide. + #[test] + fn the_gate_never_rejects_a_nonce_the_original_accepts() { + let mut rng = Rng(0xFEED_FACE_CAFE_BEEF); + let mut accepted = 0usize; + for number in NUMBERS { + let gate = DiamondSha3Gate::for_number(number); + for _ in 0..4000 { + let sha3hx = rng.easy_hash(); + // An x16rs hash that clears step 2 unconditionally, so step 1 is + // the only thing that can decide the outcome. + let x16rshx = [0u8; H32S]; + if check_diamond_difficulty(number, &sha3hx, &x16rshx) { + accepted += 1; + assert!( + gate.passes(&sha3hx), + "original accepted number={number} sha3={sha3hx:?} but the gate \ + rejected it" + ); + } + } + } + assert!(accepted > 100, "no accepting case was generated"); + } + + /// With an all-zero x16rs hash step 2 always succeeds, so + /// `check_diamond_difficulty` collapses to exactly step 1. That makes the + /// gate not merely sufficient but EQUAL to the block it copies, which is the + /// tripwire if x16rs/src/diamond.rs ever changes. + #[test] + fn the_gate_is_a_verbatim_copy_of_step_one() { + let mut rng = Rng(0x5EED_1234_ABCD_9876); + for number in NUMBERS { + let gate = DiamondSha3Gate::for_number(number); + for _ in 0..2000 { + let sha3hx = rng.easy_hash(); + assert_eq!( + gate.passes(&sha3hx), + check_diamond_difficulty(number, &sha3hx, &[0u8; H32S]), + "gate disagrees with step 1 at number={number} sha3={sha3hx:?}" + ); + } + } + // Below 42000 the MODIFFBITS loop is inert and shmaxit is 255, so the + // gate is free and passes everything, including the worst hash there is. + assert!(DiamondSha3Gate::for_number(0).passes(&[255u8; H32S])); + assert!(DiamondSha3Gate::for_number(41_999).passes(&[255u8; H32S])); + // At 65536 shmaxit drops to 254, so a leading 255 byte is rejected. + assert!(!DiamondSha3Gate::for_number(65_536).passes(&[255u8; H32S])); + } + + /// The substitution that rides along in the mining loop: replacing + /// `check_diamond_hash_result(..).is_some()` with `diamond_name_is_valid` + /// drops a Vec allocation per attempt. app/src/opencl_dia.rs relies on this + /// being equivalent; this checks the claim rather than inheriting it. + /// + /// The two are NOT equivalent on arbitrary bytes -- `check_diamond_hash_result` + /// additionally requires the last 6 chars to be in DIAMOND_HASH_BASE_CHARS. + /// They ARE equivalent on the output of `diamond_hash`, and the argument has + /// two halves, both asserted below: + /// (a) `diamond_hash` only ever emits DIAMOND_HASH_BASE_CHARS, and + /// (b) on strings over that alphabet, "not '0'" and "in the alphabet and + /// not '0'" are the same predicate. + #[test] + fn diamond_name_is_valid_matches_check_diamond_hash_result_on_diamond_hash_output() { + let mut rng = Rng(0xB16D_1A50_0FF0_1234); + // (a) alphabet closure of diamond_hash. + for _ in 0..5000 { + let dia = diamond_hash(&rng.hash()); + for c in dia.iter() { + assert!( + x16rs::DIAMOND_HASH_BASE_CHARS.contains(c), + "diamond_hash emitted {c} which is outside the base alphabet" + ); + } + // Agreement on real outputs too (all of these are invalid names at + // random, but the two must still agree). + assert_eq!( + diamond_name_is_valid(&dia), + check_diamond_hash_result(dia).is_some() + ); + } + // (b) agreement over the alphabet, with the leading-zero run forced + // across every length so the mintable shape (exactly 10) is actually hit. + let mut valid_hits = 0usize; + for lead in 0..=16usize { + for _ in 0..200 { + let mut dia = [0u8; 16]; + for (i, slot) in dia.iter_mut().enumerate() { + *slot = if i < lead { + b'0' + } else { + // index 0 of the base chars is '0'; pick from all 17 so + // that a stray '0' after the run is also exercised. + x16rs::DIAMOND_HASH_BASE_CHARS[(rng.byte() % 17) as usize] + }; + } + let a = diamond_name_is_valid(&dia); + assert_eq!( + a, + check_diamond_hash_result(dia).is_some(), + "disagree on {:?}", + String::from_utf8_lossy(&dia) + ); + if a { + valid_hits += 1; + } + } + } + assert!(valid_hits > 0, "no mintable name shape was generated"); + // And the documented difference on bytes that diamond_hash can never + // produce, so the conditional nature of the equivalence is recorded. + assert!(diamond_name_is_valid(b"0000000000ABCDEF")); + assert!(check_diamond_hash_result(b"0000000000ABCDEF").is_none()); + } #[test] fn zero_pad3_never_slices_past_the_end() { diff --git a/app/src/hpay_channel_exit.rs b/app/src/hpay_channel_exit.rs new file mode 100644 index 00000000..ce394b35 --- /dev/null +++ b/app/src/hpay_channel_exit.rs @@ -0,0 +1,487 @@ +//! Canonical, fail-closed evidence for the reviewed HPAY HVM channel-exit +//! candidate. A manifest is never authority by itself: deployment evidence is +//! derived from this node's own mainnet state. + +use basis::interface::ApiExecCtx; +use field::{Address, Hash, Hex}; +use protocol::state::CoreStateRead; +use serde_json::{Value, json}; +use vm::rt::{GasExtra, SpaceCap}; +use vm::value::Value as HvmValue; +use vm::{ContractAddress, VMStateRead}; + +pub(crate) const MAINNET_MIN_SAFE_HEIGHT: u64 = 765_432; +const MANIFEST: &str = include_str!("../../vm/contracts/hpay_channel_exit_v1.manifest.json"); +const SOURCE: &str = include_str!("../../vm/contracts/hpay_channel_exit_v1.fitsh"); + +const STORAGE_KEYS: &[&str] = &[ + "status", + "network", + "channel_id", + "reuse", + "left", + "right", + "left_deposit", + "right_deposit", + "left_paid", + "right_paid", + "total", + "serial", + "left_balance", + "right_balance", + "challenge_blocks", + "deadline", + "left_claimed", + "right_claimed", +]; + +pub(crate) fn snapshot_network_allowed( + chain_id: u32, + observed_height: u64, + deployment_height: u64, +) -> bool { + if deployment_height == 0 || deployment_height > observed_height { + return false; + } + chain_id != protocol::upgrade::MAINNET_CHAIN_ID + || (observed_height >= MAINNET_MIN_SAFE_HEIGHT + && deployment_height >= MAINNET_MIN_SAFE_HEIGHT) +} + +fn manifest_valid(manifest: &Value) -> bool { + let source_sha256 = hex::encode(sys::sha2(SOURCE.as_bytes())); + manifest["schema"] == "hpay-hvm-channel-exit-manifest/1" + && manifest["contract_name"] == "HPAYChannelExitV1" + && manifest["protocol_domain"] == "HPAY/HVM-CHANNEL/V1" + && manifest["settlement_profile"] == "hpay-hvm-channel-v1" + && manifest["source_file"] == "hpay_channel_exit_v1.fitsh" + && manifest["source_sha256"].as_str() == Some(source_sha256.as_str()) + && manifest["bytecode_sha3"] + == "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349" + && manifest["required_action_kinds"] == json!([40, 41, 44]) + && manifest["funding_model"]["left_deposit"] == "positive" + && manifest["funding_model"]["right_hub_deposit"] == "exactly_zero" + && manifest["storage_keys"] + == json!([ + "status", + "network", + "channel_id", + "reuse", + "left", + "right", + "left_deposit", + "right_deposit", + "left_paid", + "right_paid", + "total", + "serial", + "left_balance", + "right_balance", + "challenge_blocks", + "deadline", + "left_claimed", + "right_claimed" + ]) + && manifest["lease_policy"]["permissionless_renewal"] == true + && manifest["lease_policy"]["must_renew_every_storage_key"] == true + && manifest["lease_policy"]["production_watchtower_required"] == true +} + +pub(crate) fn deployment_verified( + manifest_valid: bool, + deployment: &Value, + expected_code_sha3: Option<&str>, + chain_id: Option, + observed_height: Option, + confirmed_tx_height: Option, + contract_code_sha3: Option<&str>, +) -> bool { + let Some(contract_address) = deployment["contract_address"].as_str() else { + return false; + }; + let Some(deployment_tx_hash) = deployment["deployment_tx_hash"].as_str() else { + return false; + }; + let Some(deployment_height) = deployment["deployment_height"].as_u64() else { + return false; + }; + let contract_address_valid = Address::from_readable(contract_address) + .ok() + .and_then(|address| ContractAddress::from_addr(address).ok()) + .is_some(); + manifest_valid + && deployment["enabled"].as_bool() == Some(true) + && deployment["independently_verified"].as_bool() == Some(true) + && contract_address_valid + && Hash::from_hex(deployment_tx_hash.as_bytes()).is_ok() + && deployment_height >= MAINNET_MIN_SAFE_HEIGHT + && observed_height.is_some_and(|observed| observed >= deployment_height) + && chain_id == Some(protocol::upgrade::MAINNET_CHAIN_ID) + && confirmed_tx_height == Some(deployment_height) + && expected_code_sha3.is_some() + && contract_code_sha3 == expected_code_sha3 +} + +pub(crate) fn evidence(ctx: Option<&ApiExecCtx>) -> Value { + let Ok(manifest) = serde_json::from_str::(MANIFEST) else { + return json!({ + "schema": "hpay-hvm-channel-exit-evidence/1", + "manifest_valid": false, + "deployment_verified": false, + }); + }; + let deployment = &manifest["mainnet_deployment"]; + let manifest_valid = manifest_valid(&manifest); + let contract_address = deployment["contract_address"].as_str(); + let deployment_tx_hash = deployment["deployment_tx_hash"].as_str(); + let deployment_height = deployment["deployment_height"].as_u64(); + + let mut observed_height = None; + let mut confirmed_tx_height = None; + let mut contract_code_sha3 = None; + if let (Some(ctx), Some(address), Some(tx_hash)) = (ctx, contract_address, deployment_tx_hash) { + observed_height = Some(ctx.engine.latest_block().height().uint()); + let state = ctx.engine.state(); + if let Ok(hash) = Hash::from_hex(tx_hash.as_bytes()) { + let core = protocol::state::CoreStateRead::wrap(state.as_ref().as_ref()); + confirmed_tx_height = core.tx_exist(&hash).map(|height| height.uint()); + } + if let Ok(address) = Address::from_readable(address) + && let Ok(contract) = ContractAddress::from_addr(address) + { + let hvm = VMStateRead::wrap(state.as_ref().as_ref()); + contract_code_sha3 = hvm + .contract_edition(&contract) + .map(|edition| edition.hash.to_hex()); + } + } + + let deployment_tx_confirmed = + deployment_height.is_some() && confirmed_tx_height == deployment_height; + let contract_code_matches = contract_code_sha3.as_deref() == manifest["bytecode_sha3"].as_str(); + let deployment_verified = deployment_verified( + manifest_valid, + deployment, + manifest["bytecode_sha3"].as_str(), + ctx.map(|ctx| ctx.engine.config().chain_id), + observed_height, + confirmed_tx_height, + contract_code_sha3.as_deref(), + ); + json!({ + "schema": "hpay-hvm-channel-exit-evidence/1", + "manifest_valid": manifest_valid, + "contract_name": manifest["contract_name"], + "protocol_domain": manifest["protocol_domain"], + "settlement_profile": manifest["settlement_profile"], + "source_sha256": manifest["source_sha256"], + "bytecode_sha3": manifest["bytecode_sha3"], + "required_action_kinds": manifest["required_action_kinds"], + "funding_model": manifest["funding_model"], + "storage_key_count": manifest["storage_keys"].as_array().map(Vec::len), + "must_renew_every_storage_key": manifest["lease_policy"]["must_renew_every_storage_key"], + "deployment": deployment, + "on_chain_verification": { + "observed_height": observed_height, + "confirmed_tx_height": confirmed_tx_height, + "deployment_tx_confirmed": deployment_tx_confirmed, + "contract_code_sha3": contract_code_sha3, + "contract_code_matches": contract_code_matches, + }, + "deployment_verified": deployment_verified, + }) +} + +pub(crate) fn channel_snapshot( + ctx: &ApiExecCtx, + contract_address: &str, + deployment_tx_hash: &str, + deployment_height: u64, +) -> Result { + let config = ctx.engine.config(); + let observed_height = ctx.engine.latest_block().height().uint(); + if !snapshot_network_allowed(config.chain_id, observed_height, deployment_height) { + return Err( + "HPAY HVM channel snapshot does not match the requested chain and deployment height" + .into(), + ); + } + let contract = Address::from_readable(contract_address) + .map_err(|_| "HPAY HVM channel contract address is invalid".to_owned()) + .and_then(|address| { + ContractAddress::from_addr(address) + .map_err(|_| "HPAY HVM channel address is not a contract".to_owned()) + })?; + let deployment_hash = Hash::from_hex(deployment_tx_hash.as_bytes()) + .map_err(|_| "HPAY HVM deployment transaction hash is invalid".to_owned())?; + let state = ctx.engine.state(); + let core = CoreStateRead::wrap(state.as_ref().as_ref()); + if core.tx_exist(&deployment_hash).map(|height| height.uint()) != Some(deployment_height) { + return Err("HPAY HVM deployment transaction is not included at the bound height".into()); + } + if !deployment_action_verified( + ctx, + &deployment_hash, + deployment_height, + &contract, + manifest_bytecode_sha3()?, + ) { + return Err( + "HPAY HVM deployment transaction does not deploy this exact contract artifact".into(), + ); + } + let hvm = VMStateRead::wrap(state.as_ref().as_ref()); + let code_sha3 = hvm + .contract_edition(&contract) + .map(|edition| edition.hash.to_hex()) + .ok_or_else(|| "HPAY HVM channel contract does not exist".to_owned())?; + let manifest: Value = + serde_json::from_str(MANIFEST).map_err(|_| "HPAY HVM manifest is invalid".to_owned())?; + if !manifest_valid(&manifest) || manifest["bytecode_sha3"].as_str() != Some(&code_sha3) { + return Err( + "HPAY HVM channel contract bytecode does not match the reviewed artifact".into(), + ); + } + + let evaluation_height = observed_height + .checked_add(1) + .ok_or_else(|| "HPAY HVM snapshot height overflow".to_owned())?; + let gas = GasExtra::new(evaluation_height); + let cap = SpaceCap::new(evaluation_height); + let mut storage = serde_json::Map::new(); + let mut minimum_live_blocks = u64::MAX; + let mut minimum_recover_blocks = u64::MAX; + for key in STORAGE_KEYS { + let debug = hvm + .debug_storage_get( + &gas, + &cap, + evaluation_height, + &contract.to_addr(), + &HvmValue::bytes(key.as_bytes().to_vec()), + ) + .map_err(|_| format!("HPAY HVM storage key {key} cannot be read"))? + .ok_or_else(|| format!("HPAY HVM storage key {key} is missing"))?; + let value = typed_storage_value(key, &debug.value)?; + minimum_live_blocks = minimum_live_blocks.min(debug.live_blocks); + minimum_recover_blocks = minimum_recover_blocks.min(debug.recover_blocks); + storage.insert( + (*key).to_owned(), + json!({ + "value": value, + "live_blocks": debug.live_blocks, + "recover_blocks": debug.recover_blocks, + "active": debug.active, + "recoverable": debug.recoverable, + }), + ); + } + let all_keys_active = storage + .values() + .all(|entry| entry["active"].as_bool() == Some(true)); + Ok(json!({ + "ret": 0, + "schema": "hpay-hvm-channel-live-snapshot/1", + "chain_id": config.chain_id, + "observed_height": observed_height, + "evaluation_height": evaluation_height, + "contract_address": contract.to_readable(), + "deployment_tx_hash": deployment_hash.to_hex(), + "deployment_height": deployment_height, + "deployment_action_verified": true, + "bytecode_sha3": code_sha3, + "storage_key_count": STORAGE_KEYS.len(), + "all_keys_active": all_keys_active, + "minimum_live_blocks": minimum_live_blocks, + "minimum_recover_blocks": minimum_recover_blocks, + "storage": storage, + })) +} + +fn manifest_bytecode_sha3() -> Result<&'static str, String> { + const EXPECTED: &str = "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349"; + Ok(EXPECTED) +} + +fn deployment_action_verified( + ctx: &ApiExecCtx, + expected_tx_hash: &Hash, + deployment_height: u64, + expected_contract: &ContractAddress, + expected_code_sha3: &str, +) -> bool { + crate::hpay_contract_deployment::verify_contract_deployment( + ctx, + expected_tx_hash, + deployment_height, + expected_contract, + expected_code_sha3, + ) + .is_some() +} + +#[cfg(test)] +fn deployment_transaction_matches( + transaction: &dyn basis::interface::TransactionRead, + expected_contract: &ContractAddress, + expected_code_sha3: &str, +) -> bool { + crate::hpay_contract_deployment::matching_deployment_in_transaction( + transaction, + expected_contract, + expected_code_sha3, + ) + .is_some() +} + +fn typed_storage_value(key: &str, value: &HvmValue) -> Result { + let invalid = || format!("HPAY HVM storage key {key} has an unexpected type"); + match key { + "status" => match value { + HvmValue::U8(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + "reuse" => match value { + HvmValue::U32(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + "network" => match value { + HvmValue::Bytes(bytes) if bytes.len() == 32 => Ok(Value::from(hex::encode(bytes))), + _ => Err(invalid()), + }, + "channel_id" => match value { + HvmValue::Bytes(bytes) if bytes.len() == 16 => Ok(Value::from(hex::encode(bytes))), + _ => Err(invalid()), + }, + "left" | "right" => value + .extract_address() + .map(|address| Value::from(address.to_readable())) + .map_err(|_| invalid()), + "left_claimed" | "right_claimed" => match value { + HvmValue::Bool(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + // `hac_to_zhu` is a U128 native result. The reviewed contract stores + // the initial paid counters as U64, then writes that native result + // after funding. Accept only unsigned values that losslessly fit the + // protocol's u64 Zhu fields; all other storage widths remain strict. + "left_paid" | "right_paid" => value.extract_u64().map(Value::from).map_err(|_| invalid()), + _ => match value { + HvmValue::U64(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use basis::interface::Transaction; + use field::{Amount, Field, Uint4}; + use protocol::transaction::TransactionType3; + use sys::Account; + + #[test] + fn snapshot_network_policy_keeps_mainnet_strict_and_allows_isolated_testnet() { + assert!(snapshot_network_allowed( + protocol::upgrade::MAINNET_CHAIN_ID, + MAINNET_MIN_SAFE_HEIGHT, + MAINNET_MIN_SAFE_HEIGHT + )); + assert!(!snapshot_network_allowed( + protocol::upgrade::MAINNET_CHAIN_ID, + MAINNET_MIN_SAFE_HEIGHT, + MAINNET_MIN_SAFE_HEIGHT - 1 + )); + assert!(snapshot_network_allowed(7, 2, 1)); + assert!(!snapshot_network_allowed(7, 1, 0)); + assert!(!snapshot_network_allowed(7, 1, 2)); + } + + #[test] + fn storage_schema_requires_exact_hvm_widths_and_shapes() { + assert_eq!( + typed_storage_value("status", &HvmValue::U8(2)).unwrap(), + json!(2) + ); + assert!(typed_storage_value("status", &HvmValue::U64(2)).is_err()); + assert_eq!( + typed_storage_value("reuse", &HvmValue::U32(7)).unwrap(), + json!(7) + ); + assert!(typed_storage_value("reuse", &HvmValue::U8(7)).is_err()); + assert_eq!( + typed_storage_value("left_deposit", &HvmValue::U64(10)).unwrap(), + json!(10) + ); + assert!(typed_storage_value("left_deposit", &HvmValue::U32(10)).is_err()); + assert_eq!( + typed_storage_value("left_paid", &HvmValue::U128(10)).unwrap(), + json!(10) + ); + assert_eq!( + typed_storage_value("right_paid", &HvmValue::U64(0)).unwrap(), + json!(0) + ); + assert!( + typed_storage_value("left_paid", &HvmValue::U128(u128::from(u64::MAX) + 1)).is_err() + ); + assert!(typed_storage_value("left_paid", &HvmValue::Bytes(vec![10])).is_err()); + assert_eq!( + typed_storage_value("left_claimed", &HvmValue::Bool(false)).unwrap(), + json!(false) + ); + assert!(typed_storage_value("left_claimed", &HvmValue::U8(0)).is_err()); + assert!(typed_storage_value("network", &HvmValue::Bytes(vec![1; 31])).is_err()); + assert!(typed_storage_value("channel_id", &HvmValue::Bytes(vec![1; 17])).is_err()); + } + + #[test] + fn manifest_and_endpoint_cover_the_same_exact_storage_inventory() { + let manifest: Value = serde_json::from_str(MANIFEST).unwrap(); + let keys = manifest["storage_keys"].as_array().unwrap(); + assert_eq!(keys.len(), STORAGE_KEYS.len()); + for (manifest_key, expected) in keys.iter().zip(STORAGE_KEYS) { + assert_eq!(manifest_key.as_str(), Some(*expected)); + } + } + + #[test] + fn deployment_transaction_is_bound_to_exact_derived_address_and_code() { + let deployer = Account::create_by("hpay-deployment-proof").unwrap(); + let deployer_address = Address::from(deployer.address().clone()); + let nonce = Uint4::from(7); + let expected_contract = ContractAddress::calculate(&deployer_address, &nonce); + let compiled = vm::fitshc::compile(SOURCE).unwrap().0.into_sto(); + let expected_code = compiled.calc_edition().hash.to_hex(); + let mut deploy = vm::action::ContractDeploy::new(); + deploy.nonce = nonce; + deploy.contract = compiled; + let mut transaction = TransactionType3::new_by(deployer_address, Amount::unit238(1), 1); + transaction.push_action(Box::new(deploy.clone())).unwrap(); + assert!(deployment_transaction_matches( + &transaction, + &expected_contract, + &expected_code + )); + + let wrong_contract = ContractAddress::calculate(&deployer_address, &Uint4::from(8)); + assert!(!deployment_transaction_matches( + &transaction, + &wrong_contract, + &expected_code + )); + assert!(!deployment_transaction_matches( + &transaction, + &expected_contract, + &"ff".repeat(32) + )); + + transaction.push_action(Box::new(deploy)).unwrap(); + assert!(!deployment_transaction_matches( + &transaction, + &expected_contract, + &expected_code + )); + } +} diff --git a/app/src/hpay_channel_registry.rs b/app/src/hpay_channel_registry.rs new file mode 100644 index 00000000..40b1ea87 --- /dev/null +++ b/app/src/hpay_channel_registry.rs @@ -0,0 +1,385 @@ +//! Canonical fullnode snapshot for the reviewed shared HPAY HVM registry. +//! The endpoint proves the exact deployment transaction, constructor binding, +//! bytecode and all registry/channel storage leases from this node's own state. + +use basis::interface::ApiExecCtx; +use field::{Address, Hash, Hex}; +use protocol::state::CoreStateRead; +use serde_json::{Value, json}; +use vm::rt::{GasExtra, SpaceCap}; +use vm::value::Value as HvmValue; +use vm::{ContractAddress, VMStateRead}; + +const MANIFEST: &str = include_str!("../../vm/contracts/hpay_channel_registry_v2.manifest.json"); +const SOURCE: &str = include_str!("../../vm/contracts/hpay_channel_registry_v2.fitsh"); +const EXPECTED_BYTECODE_SHA3: &str = + "276d8c205296cc50d06244c84d52c5a9f6f4711e0abae67f416e4fc79c9294be"; + +const REGISTRY_KEYS: &[&str] = &[ + "g_network", + "g_hub", + "g_locked", + "g_left_claimable", + "g_hub_claimable", + "g_open_count", +]; + +const CHANNEL_FIELDS: &[(&str, &str)] = &[ + ("status", "c_status_"), + ("channel_id", "c_id_"), + ("reuse", "c_reuse_"), + ("deposit", "c_deposit_"), + ("paid", "c_paid_"), + ("total", "c_total_"), + ("serial", "c_serial_"), + ("left_balance", "c_left_balance_"), + ("hub_balance", "c_hub_balance_"), + ("challenge_blocks", "c_challenge_"), + ("deadline", "c_deadline_"), + ("left_claimed", "c_left_claimed_"), +]; + +fn manifest_valid(manifest: &Value) -> bool { + let source_sha256 = hex::encode(sys::sha2(SOURCE.as_bytes())); + manifest["schema"] == "hpay-hvm-channel-registry-manifest/2" + && manifest["contract_name"] == "HPAYChannelRegistryV2" + && manifest["protocol_domain"] == "HPAY/HVM-CHANNEL-REGISTRY/V2" + && manifest["settlement_profile"] == "hpay-hvm-shared-registry-v2" + && manifest["source_file"] == "hpay_channel_registry_v2.fitsh" + && manifest["source_sha256"].as_str() == Some(source_sha256.as_str()) + && manifest["bytecode_sha3"] == EXPECTED_BYTECODE_SHA3 + && manifest["required_action_kinds"] == json!([40, 41, 44]) + && manifest["deployment_model"]["scope"] == "one_registry_per_hub_and_network" + && manifest["deployment_model"]["hub_binding"] == "contract_deploy_main_signer" + && manifest["deployment_model"]["network_binding"] == "exact_32_byte_constructor_argument" + && manifest["deployment_model"]["per_channel_contract_deploy"] == false + && manifest["channel_model"]["maximum_active_channels_per_left_address"] == 1 + && manifest["channel_model"]["first_reuse"] == 0 + && manifest["channel_model"]["right_hub_deposit"] == "exactly_zero" + && manifest["registry_storage_keys"] == json!(REGISTRY_KEYS) + && manifest["channel_storage_prefixes"] + == json!( + CHANNEL_FIELDS + .iter() + .map(|(_, prefix)| *prefix) + .collect::>() + ) + && manifest["lease_policy"]["permissionless_registry_renewal"] == true + && manifest["lease_policy"]["permissionless_channel_renewal"] == true + && manifest["lease_policy"]["must_renew_every_registry_key"] == true + && manifest["lease_policy"]["must_renew_every_channel_key"] == true + && manifest["lease_policy"]["production_watchtower_required"] == true +} + +fn channel_key(prefix: &str, left: &Address) -> HvmValue { + let mut key = prefix.as_bytes().to_vec(); + key.extend_from_slice(left.as_bytes()); + HvmValue::bytes(key) +} + +fn debug_entry( + hvm: &VMStateRead, + gas: &GasExtra, + cap: &SpaceCap, + evaluation_height: u64, + contract: &ContractAddress, + key: HvmValue, + label: &str, + value: impl FnOnce(&HvmValue) -> Result, +) -> Result<(Value, u64, u64, bool), String> { + let debug = hvm + .debug_storage_get(gas, cap, evaluation_height, &contract.to_addr(), &key) + .map_err(|_| format!("HPAY HVM registry storage {label} cannot be read"))? + .ok_or_else(|| format!("HPAY HVM registry storage {label} is missing"))?; + Ok(( + json!({ + "value": value(&debug.value)?, + "live_blocks": debug.live_blocks, + "recover_blocks": debug.recover_blocks, + "active": debug.active, + "recoverable": debug.recoverable, + }), + debug.live_blocks, + debug.recover_blocks, + debug.active, + )) +} + +fn registry_value(key: &str, value: &HvmValue) -> Result { + let invalid = || format!("HPAY HVM registry key {key} has an unexpected type"); + match key { + "g_network" => match value { + HvmValue::Bytes(bytes) if bytes.len() == 32 => Ok(Value::from(hex::encode(bytes))), + _ => Err(invalid()), + }, + "g_hub" => value + .extract_address() + .map(|address| Value::from(address.to_readable())) + .map_err(|_| invalid()), + _ => match value { + HvmValue::U64(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + } +} + +fn channel_value(field: &str, value: &HvmValue) -> Result { + let invalid = || format!("HPAY HVM registry channel field {field} has an unexpected type"); + match field { + "status" => match value { + HvmValue::U8(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + "channel_id" => match value { + HvmValue::Bytes(bytes) if bytes.len() == 16 => Ok(Value::from(hex::encode(bytes))), + _ => Err(invalid()), + }, + "reuse" => match value { + HvmValue::U32(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + "left_claimed" => match value { + HvmValue::Bool(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + _ => match value { + HvmValue::U64(value) => Ok(Value::from(*value)), + _ => Err(invalid()), + }, + } +} + +fn validate_channel_invariants(channel: &serde_json::Map) -> Result<(), String> { + let value = |name: &str| { + channel + .get(name) + .and_then(|entry| entry["value"].as_u64()) + .ok_or_else(|| format!("HPAY HVM registry channel field {name} is invalid")) + }; + let status = value("status")?; + let deposit = value("deposit")?; + let paid = value("paid")?; + let total = value("total")?; + let left = value("left_balance")?; + let hub = value("hub_balance")?; + if !(1..=4).contains(&status) || deposit == 0 || total != deposit { + return Err("HPAY HVM registry channel funding invariant failed".into()); + } + if !matches!(paid, 0) && paid != deposit { + return Err("HPAY HVM registry channel paid amount is invalid".into()); + } + if status == 1 && paid != 0 { + return Err("HPAY HVM registry funding channel already reports a deposit".into()); + } + if status >= 2 && paid != deposit { + return Err("HPAY HVM registry live channel is not exactly funded".into()); + } + if left.checked_add(hub) != Some(total) { + return Err("HPAY HVM registry channel balances do not conserve the deposit".into()); + } + Ok(()) +} + +pub(crate) fn channel_snapshot( + ctx: &ApiExecCtx, + contract_address: &str, + deployment_tx_hash: &str, + deployment_height: u64, + left_address: &str, + expected_network_instance_id: &str, +) -> Result { + let config = ctx.engine.config(); + let observed_height = ctx.engine.latest_block().height().uint(); + if !crate::hpay_channel_exit::snapshot_network_allowed( + config.chain_id, + observed_height, + deployment_height, + ) { + return Err( + "HPAY HVM registry snapshot does not match the requested chain and deployment height" + .into(), + ); + } + if expected_network_instance_id.len() != 64 + || !expected_network_instance_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("HPAY HVM registry network instance is invalid".into()); + } + let expected_network = hex::decode(expected_network_instance_id) + .map_err(|_| "HPAY HVM registry network instance is invalid".to_owned())?; + let contract = Address::from_readable(contract_address) + .map_err(|_| "HPAY HVM registry contract address is invalid".to_owned()) + .and_then(|address| { + ContractAddress::from_addr(address) + .map_err(|_| "HPAY HVM registry address is not a contract".to_owned()) + })?; + let left = Address::from_readable(left_address) + .map_err(|_| "HPAY HVM registry left address is invalid".to_owned())?; + let deployment_hash = Hash::from_hex(deployment_tx_hash.as_bytes()) + .map_err(|_| "HPAY HVM registry deployment transaction hash is invalid".to_owned())?; + let state = ctx.engine.state(); + let core = CoreStateRead::wrap(state.as_ref().as_ref()); + if core.tx_exist(&deployment_hash).map(|height| height.uint()) != Some(deployment_height) { + return Err( + "HPAY HVM registry deployment transaction is not included at the bound height".into(), + ); + } + let deployment = crate::hpay_contract_deployment::verify_contract_deployment( + ctx, + &deployment_hash, + deployment_height, + &contract, + EXPECTED_BYTECODE_SHA3, + ) + .ok_or_else(|| { + "HPAY HVM registry deployment does not contain the exact reviewed artifact".to_owned() + })?; + if deployment.construct_argv != expected_network { + return Err("HPAY HVM registry constructor is not bound to this network instance".into()); + } + + let manifest: Value = + serde_json::from_str(MANIFEST).map_err(|_| "HPAY HVM registry manifest is invalid")?; + if !manifest_valid(&manifest) { + return Err("HPAY HVM registry manifest does not match the reviewed artifact".into()); + } + let hvm = VMStateRead::wrap(state.as_ref().as_ref()); + let code_sha3 = hvm + .contract_edition(&contract) + .map(|edition| edition.hash.to_hex()) + .ok_or_else(|| "HPAY HVM registry contract does not exist".to_owned())?; + if code_sha3 != EXPECTED_BYTECODE_SHA3 { + return Err("HPAY HVM registry bytecode does not match the reviewed artifact".into()); + } + + let evaluation_height = observed_height + .checked_add(1) + .ok_or_else(|| "HPAY HVM registry snapshot height overflow".to_owned())?; + let gas = GasExtra::new(evaluation_height); + let cap = SpaceCap::new(evaluation_height); + let mut registry = serde_json::Map::new(); + let mut channel = serde_json::Map::new(); + let mut minimum_live_blocks = u64::MAX; + let mut minimum_recover_blocks = u64::MAX; + let mut all_keys_active = true; + for key in REGISTRY_KEYS { + let (entry, live, recover, active) = debug_entry( + &hvm, + &gas, + &cap, + evaluation_height, + &contract, + HvmValue::bytes(key.as_bytes().to_vec()), + key, + |value| registry_value(key, value), + )?; + minimum_live_blocks = minimum_live_blocks.min(live); + minimum_recover_blocks = minimum_recover_blocks.min(recover); + all_keys_active &= active; + registry.insert((*key).to_owned(), entry); + } + for (field, prefix) in CHANNEL_FIELDS { + let (entry, live, recover, active) = debug_entry( + &hvm, + &gas, + &cap, + evaluation_height, + &contract, + channel_key(prefix, &left), + field, + |value| channel_value(field, value), + )?; + minimum_live_blocks = minimum_live_blocks.min(live); + minimum_recover_blocks = minimum_recover_blocks.min(recover); + all_keys_active &= active; + channel.insert((*field).to_owned(), entry); + } + if registry["g_network"]["value"].as_str() != Some(expected_network_instance_id) { + return Err("HPAY HVM registry storage network binding is invalid".into()); + } + if registry["g_hub"]["value"].as_str() != Some(deployment.main_address.to_readable().as_str()) { + return Err("HPAY HVM registry Hub does not match the deployment signer".into()); + } + if left == deployment.main_address { + return Err("HPAY HVM registry left address cannot be the Hub".into()); + } + validate_channel_invariants(&channel)?; + + Ok(json!({ + "ret": 0, + "schema": "hpay-hvm-channel-registry-live-snapshot/2", + "settlement_profile": "hpay-hvm-shared-registry-v2", + "chain_id": config.chain_id, + "network_instance_id": expected_network_instance_id, + "observed_height": observed_height, + "evaluation_height": evaluation_height, + "contract_address": contract.to_readable(), + "deployment_tx_hash": deployment_hash.to_hex(), + "deployment_height": deployment_height, + "deployment_action_verified": true, + "bytecode_sha3": code_sha3, + "hub_address": deployment.main_address.to_readable(), + "left_address": left.to_readable(), + "registry_key_count": REGISTRY_KEYS.len(), + "channel_key_count": CHANNEL_FIELDS.len(), + "all_keys_active": all_keys_active, + "minimum_live_blocks": minimum_live_blocks, + "minimum_recover_blocks": minimum_recover_blocks, + "registry": registry, + "channel": channel, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_inventory_and_hashes_are_exact() { + let manifest: Value = serde_json::from_str(MANIFEST).unwrap(); + assert!(manifest_valid(&manifest)); + assert_eq!(manifest["registry_storage_keys"], json!(REGISTRY_KEYS)); + assert_eq!( + manifest["channel_storage_prefixes"], + json!( + CHANNEL_FIELDS + .iter() + .map(|(_, prefix)| *prefix) + .collect::>() + ) + ); + let compiled = vm::fitshc::compile(SOURCE).unwrap().0.serialize(); + assert_eq!(hex::encode(sys::sha3(compiled)), EXPECTED_BYTECODE_SHA3); + } + + #[test] + fn typed_storage_and_conservation_fail_closed() { + assert_eq!( + registry_value("g_locked", &HvmValue::U64(3)).unwrap(), + json!(3) + ); + assert!(registry_value("g_locked", &HvmValue::U128(3)).is_err()); + assert_eq!( + channel_value("channel_id", &HvmValue::Bytes(vec![1; 16])).unwrap(), + json!("01010101010101010101010101010101") + ); + assert!(channel_value("channel_id", &HvmValue::Bytes(vec![1; 15])).is_err()); + + let mut channel = serde_json::Map::new(); + for (key, value) in [ + ("status", json!({"value": 2})), + ("deposit", json!({"value": 100})), + ("paid", json!({"value": 100})), + ("total", json!({"value": 100})), + ("left_balance", json!({"value": 60})), + ("hub_balance", json!({"value": 40})), + ] { + channel.insert(key.into(), value); + } + assert!(validate_channel_invariants(&channel).is_ok()); + channel["hub_balance"]["value"] = json!(41); + assert!(validate_channel_invariants(&channel).is_err()); + } +} diff --git a/app/src/hpay_contract_deployment.rs b/app/src/hpay_contract_deployment.rs new file mode 100644 index 00000000..f19888ef --- /dev/null +++ b/app/src/hpay_contract_deployment.rs @@ -0,0 +1,105 @@ +use basis::interface::{ApiExecCtx, TransactionRead}; +use field::{Address, BlockHeight, Hash, Hex}; +use vm::ContractAddress; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VerifiedContractDeployment { + pub main_address: Address, + pub construct_argv: Vec, +} + +pub(crate) fn verify_contract_deployment( + ctx: &ApiExecCtx, + expected_tx_hash: &Hash, + deployment_height: u64, + expected_contract: &ContractAddress, + expected_code_sha3: &str, +) -> Option { + let (stored_block_hash, bytes) = ctx + .engine + .store() + .block_data_by_height(&BlockHeight::from(deployment_height))?; + let package = protocol::block::build_block_package(bytes).ok()?; + if package.hash() != stored_block_hash || package.hein() != deployment_height { + return None; + } + let matching_transactions = package + .block_read() + .transactions() + .iter() + .filter(|transaction| transaction.hash() == *expected_tx_hash) + .collect::>(); + if matching_transactions.len() != 1 { + return None; + } + matching_deployment_in_transaction( + matching_transactions[0].as_read(), + expected_contract, + expected_code_sha3, + ) +} + +pub(crate) fn matching_deployment_in_transaction( + transaction: &dyn TransactionRead, + expected_contract: &ContractAddress, + expected_code_sha3: &str, +) -> Option { + let matches = transaction + .actions() + .iter() + .filter_map(|action| vm::action::ContractDeploy::downcast(action)) + .filter(|deploy| { + ContractAddress::calculate(&transaction.main(), &deploy.nonce) == *expected_contract + && deploy.contract.calc_edition().hash.to_hex() == expected_code_sha3 + }) + .collect::>(); + if matches.len() != 1 { + return None; + } + Some(VerifiedContractDeployment { + main_address: transaction.main(), + construct_argv: matches[0].construct_argv.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use basis::interface::Transaction; + use field::{Amount, Field, Uint4}; + use protocol::transaction::TransactionType3; + use sys::Account; + + #[test] + fn exact_deployment_returns_main_and_constructor_bytes() { + let deployer = Account::create_by("hpay-shared-deployment-proof").unwrap(); + let main = Address::from(deployer.address().clone()); + let nonce = Uint4::from(7); + let contract = ContractAddress::calculate(&main, &nonce); + let source = include_str!("../../vm/contracts/hpay_channel_registry_v2.fitsh"); + let compiled = vm::fitshc::compile(source).unwrap().0.into_sto(); + let code_hash = compiled.calc_edition().hash.to_hex(); + let mut deploy = vm::action::ContractDeploy::new(); + deploy.nonce = nonce; + deploy.construct_argv = field::BytesW2::from(vec![0x44; 32]).unwrap(); + deploy.contract = compiled; + let mut transaction = TransactionType3::new_by(main, Amount::unit238(1), 1); + transaction.push_action(Box::new(deploy.clone())).unwrap(); + + assert_eq!( + matching_deployment_in_transaction(&transaction, &contract, &code_hash), + Some(VerifiedContractDeployment { + main_address: main, + construct_argv: vec![0x44; 32], + }) + ); + assert!( + matching_deployment_in_transaction(&transaction, &contract, &"ff".repeat(32)).is_none() + ); + transaction.push_action(Box::new(deploy)).unwrap(); + assert!( + matching_deployment_in_transaction(&transaction, &contract, &code_hash).is_none(), + "ambiguous duplicate deployments must fail closed" + ); + } +} diff --git a/app/src/lib.rs b/app/src/lib.rs index ffe6e0b7..cbc3cf3c 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -6,6 +6,10 @@ include! {"version.rs"} #[macro_use] pub mod worker_log; +/// How many CPU threads a worker takes, derived from the machine's own logical +/// CPU count. Shared by the workers and the panel so a shipped config, a GUI +/// preset and a running worker cannot disagree about what "all cores" means. +pub mod cpu_threads; pub mod efficiency; pub mod gpu_arch; pub mod gpu_oom; @@ -14,15 +18,26 @@ pub mod gpu_oom; #[cfg(windows)] pub mod gpu_temp_adl; pub mod hash_util; +mod hpay_channel_exit; +mod hpay_channel_registry; +mod hpay_contract_deployment; pub mod mining_batch; pub mod mining_guard; pub mod mining_runtime; pub mod mining_stats; +/// What an NVIDIA card can hold of the x16rs batch kernel, derived from the +/// kernel's own attributes and the published per-multiprocessor budgets. No +/// CUDA, so the NVIDIA search space and the NVIDIA presets are testable on a +/// machine with no NVIDIA card in it. +pub mod nvidia_launch; pub mod panel_tuning; pub mod rpc_http; #[macro_use] mod mining_util; +/// Auto-tune measured at the mainnet x16rs repeat, on a corpus frozen for the +/// whole session, scored on the watts the card reports. +pub mod autotune16; pub mod bench_mainnet_repeat16; pub mod diaworker; pub mod opencl_diag; @@ -31,6 +46,9 @@ pub mod opencl_gpu; #[cfg(feature = "ocl")] pub mod opencl_list; pub mod poworker; +/// Byte-equivalence gate + fixed-work baseline for the OpenCL x16rs kernel. +/// Never called by the mining path; it is the measuring instrument. +pub mod x16rs_gate; // pub mod svrapi; // server api pub mod diabider; pub mod fullnode; diff --git a/app/src/mining_runtime.rs b/app/src/mining_runtime.rs index 771a5a1c..cc7f9c96 100644 --- a/app/src/mining_runtime.rs +++ b/app/src/mining_runtime.rs @@ -42,6 +42,15 @@ pub struct MiningRuntimeState { /// what a pass on this rig can cost, so the window and the rate readings can /// actually arrive at can never contradict each other. gpu_temp_fresh_ms: AtomicU64, + /// Total board power really measured across every GPU in this rig, in + /// hundredths of a watt. 0 means no card measured it, which is not the same + /// fact as a card drawing nothing and must never reach the panel as a zero: + /// the operator's bill would read as free. + gpu_power_w100: AtomicU32, + /// Wall clock of that reading. Shares the temperature's freshness window + /// because it is taken in the same monitor pass, from the same sensor, at + /// the same cadence. 0 = never read. + gpu_power_unix_ms: AtomicU64, } pub(crate) struct MiningThreadGuard { @@ -73,6 +82,8 @@ impl MiningRuntimeState { // Before a monitor starts there is nothing to be stale, so the // narrowest cadence with a single sensor is the safe placeholder. gpu_temp_fresh_ms: AtomicU64::new(temp_freshness_ms(ThermalCadence::GUARDED, 1)), + gpu_power_w100: AtomicU32::new(0), + gpu_power_unix_ms: AtomicU64::new(0), }) } @@ -114,6 +125,39 @@ impl MiningRuntimeState { Some(hundredths as f32 / 100.0) } + /// Publish the rig's total measured board power, in watts. Anything outside + /// the window a board draw can fall in is dropped rather than published, on + /// the same rule the temperature above applies, and for the same reason: a + /// number that is not a measurement must not be able to look like one. + pub fn record_gpu_board_power_w(&self, watts: f32) { + let Some(watts) = crate::mining_stats::sensor_board_power_w(Some(watts)) else { + return; + }; + self.gpu_power_w100 + .store((watts * 100.0).round() as u32, Relaxed); + self.gpu_power_unix_ms + .store(crate::mining_stats::unix_ms_now(), Relaxed); + } + + /// The GPU board power this process measured, or `None` when no card has + /// answered recently. Never 0, for the same reason `gpu_temp_c` is never 0: + /// where there is no measurement the caller has to fall back to its + /// configured estimate and say so, and a zero would instead be published as + /// a rig that costs nothing to run. + pub fn gpu_board_power_w(&self) -> Option { + let hundredths = self.gpu_power_w100.load(Relaxed); + let taken_at = self.gpu_power_unix_ms.load(Relaxed); + if hundredths == 0 || taken_at == 0 { + return None; + } + if crate::mining_stats::unix_ms_now().saturating_sub(taken_at) + > self.gpu_temp_fresh_ms.load(Relaxed) + { + return None; + } + Some(hundredths as f32 / 100.0) + } + pub(crate) fn track_mining_thread(self: &Arc) -> MiningThreadGuard { self.active_mining_threads.fetch_add(1, AcqRel); MiningThreadGuard { @@ -304,13 +348,17 @@ impl MiningRuntimeState { if newly_throttled || cap_changed { wlogerr!( "[Thermal] {:.1}C >= {:.1}C: cap work_groups to {}", - temp_c, max_temp, cap + temp_c, + max_temp, + cap ); } if !self.thermal_paused.swap(true, Relaxed) { wlogerr!( "[Thermal] CRITICAL {:.1}C >= {:.1}C: mining paused until <= {:.1}C", - temp_c, critical_temp, recovery_temp + temp_c, + critical_temp, + recovery_temp ); } return; @@ -321,7 +369,9 @@ impl MiningRuntimeState { if !self.throttled.swap(true, Relaxed) || cap_changed { wlogerr!( "[Thermal] {:.1}C >= {:.1}C: cap work_groups to {}", - temp_c, max_temp, cap + temp_c, + max_temp, + cap ); } return; @@ -334,7 +384,8 @@ impl MiningRuntimeState { if was_paused || was_throttled || old_cap > 0 { wlogln!( "[Thermal] Recovered at {:.1}C (<= {:.1}C): mining resumed, cap removed", - temp_c, recovery_temp + temp_c, + recovery_temp ); } } @@ -382,14 +433,28 @@ impl MiningRuntimeState { /// What one sensor pass costs. /// -/// A pass probes every selected GPU, one after another, and each probe is a -/// subprocess (or, with `thermal_file`, one file read). A probe that answers -/// costs milliseconds; a probe that stalls is bounded by SENSOR_COMMAND_TIMEOUT -/// (2s) plus the bounded kill of a process that ignored it, which is the 2.5s -/// per GPU used here. So a healthy 8-GPU rig finishes a pass in well under a -/// second and a sick one spends 20s in it, and every interval below is chosen -/// against that number rather than against a hoped-for one. -const SENSOR_PASS_WORST_CASE_PER_GPU: Duration = Duration::from_millis(2_500); +/// A pass probes every selected GPU, one after another, and asks it up to two +/// questions: its temperature, and its board draw. Each question is a subprocess +/// (or, with `thermal_file` or the AMD display driver, a file read or a library +/// call). A probe that answers costs milliseconds; a probe that stalls is +/// bounded by SENSOR_COMMAND_TIMEOUT (2s) plus the bounded kill of a process +/// that ignored it, which is 2.5s each, so 5s per GPU in the worst case. A +/// healthy 8-GPU rig finishes a pass in well under a second and a sick one +/// spends 40s in it, and every interval below is chosen against that number +/// rather than against a hoped-for one. +/// +/// It was 2.5s while only the AMD display driver could answer with watts, which +/// costs no subprocess at all, so the power half of a pass was free everywhere +/// else: `read_board_power_w` returned `None` for every command source without +/// spawning anything. Wiring `nvidia-smi --query-gpu=power.draw` gives an NVIDIA +/// rig a real second subprocess per card, and a bound that did not follow it +/// would understate a slow rig's own sampling period and blank a working gauge +/// between two good samples. Two probes' worth is charged for every GPU rather +/// than only for the ones that really ask twice: this is the ceiling used to +/// decide how old a published reading may get, and paying a little too much for +/// an AMD rig only widens a display window, while paying too little would hide a +/// reading that the rig is still producing. +const SENSOR_PASS_WORST_CASE_PER_GPU: Duration = Duration::from_millis(5_000); fn worst_case_pass(sensor_count: usize) -> Duration { let sensors = u32::try_from(sensor_count.max(1)).unwrap_or(u32::MAX); @@ -417,11 +482,18 @@ const THERMAL_MIN_IDLE: Duration = Duration::from_millis(2_500); const THERMAL_POLL_INTERVAL: Duration = Duration::from_millis(2_500); /// Reporting-only cadence (`max_temp_c == 0`, the shipped default). This is a -/// number on a screen, not a safety input. 30s is comfortably longer than the -/// worst-case pass above (~20s on an 8-GPU rig), keeps the duty cycle low on a -/// healthy rig (sub-second pass, then idle), and still refreshes the gauge -/// twice a minute, which is faster than a GPU changes temperature in any way an +/// number on a screen, not a safety input. 30s keeps the duty cycle low on a +/// healthy rig (sub-second pass, then idle) and still refreshes the gauge twice +/// a minute, which is faster than a GPU changes temperature in any way an /// operator who set no limit needs to watch. +/// +/// It is no longer longer than the worst-case pass on a large rig: eight cards +/// whose every probe times out is 40s of pass against a 30s interval. That case +/// is a rig whose sensors have all stopped answering, and it is bounded by +/// THERMAL_MIN_IDLE exactly as the guarded path is, so the loop degrades to +/// pass-then-2.5s-idle rather than to back-to-back spawns. Stated rather than +/// papered over: the interval is chosen for the healthy rig, and the floor is +/// what protects the sick one. const THERMAL_REPORTING_POLL_INTERVAL: Duration = Duration::from_secs(30); /// How the monitor spaces one sensor pass from the next. @@ -479,8 +551,8 @@ impl ThermalCadence { /// blanks its own gauge. This is a display window only; the safety path acts on /// each reading as it arrives and never consults it. fn temp_freshness_ms(cadence: ThermalCadence, sensor_count: usize) -> u64 { - let gap = u64::try_from(cadence.worst_case_sample_gap(sensor_count).as_millis()) - .unwrap_or(u64::MAX); + let gap = + u64::try_from(cadence.worst_case_sample_gap(sensor_count).as_millis()).unwrap_or(u64::MAX); gap.saturating_mul(2).saturating_add(5_000).max(15_000) } @@ -621,6 +693,15 @@ fn run_thermal_monitor( // still cannot spawn its next subprocess immediately. let pass_started = Instant::now(); let reading = hottest_sensor_reading(sensors); + // Power is read in the same pass and published on its own. It is + // deliberately not routed through `handle_thermal_sample`: a missing + // temperature is a safety event that can pause a rig, while a missing + // power reading only means the gauge ages out and the panel goes back + // to saying "estimate". Merging the two would let a card that reports + // heat but not watts trip the safety path. + if let Some(watts) = total_board_power_reading(sensors) { + runtime.record_gpu_board_power_w(watts); + } handle_thermal_sample( runtime, reading, @@ -642,6 +723,29 @@ fn hottest_sensor_reading(sensors: &[crate::efficiency::GpuTempSensorBackend]) - hottest } +/// What the whole rig's GPUs are drawing, in watts, as a SUM and not a maximum. +/// +/// Temperature takes the hottest card because the hottest card is the one that +/// will throttle or die. Power takes every card added together, because the +/// question it answers is what the meter is spinning at, and eight cards each +/// drawing 250 W cost eight times as much as one. +/// +/// One card that cannot report power makes the whole total `None`, on purpose. A +/// sum over the subset that answered is not the rig's draw, it is a fraction of +/// it wearing the same label, and understating the bill is exactly the failure +/// this whole change exists to end. Better an honest fallback to the configured +/// estimate for the rig than a measurement that is quietly missing a card. +fn total_board_power_reading(sensors: &[crate::efficiency::GpuTempSensorBackend]) -> Option { + if sensors.is_empty() { + return None; + } + let mut total = 0.0f32; + for sensor in sensors { + total += sensor.read_board_power_w()?; + } + Some(total) +} + /// How many times to retry spawning the thermal monitor thread before giving up /// and fail-closing. A working sensor is already detected by this point, so a /// spawn failure is a transient OS resource problem worth retrying. @@ -695,7 +799,8 @@ fn detect_thermal_sensors( // The single configured thermal_file describes the first identity only; // the caller has already refused to let it stand in for several GPUs. let file = if sensors.is_empty() { thermal_file } else { "" }; - let Some((sensor, temp)) = crate::efficiency::detect_gpu_temp_sensor(file, gpu_index, vendor) + let Some((sensor, temp)) = + crate::efficiency::detect_gpu_temp_sensor(file, gpu_index, vendor) else { return Err(no_sensor_reason(vendor, gpu_index)); }; @@ -741,6 +846,9 @@ fn spawn_reporting_only_thermal_monitor( } }; monitor_runtime.record_gpu_temperature(initial); + if let Some(watts) = total_board_power_reading(&sensors) { + monitor_runtime.record_gpu_board_power_w(watts); + } run_thermal_monitor( &monitor_runtime, &sensors, @@ -826,6 +934,9 @@ pub fn start_thermal_monitor( }; runtime.record_gpu_temperature(initial_hottest); + if let Some(watts) = total_board_power_reading(&sensors) { + runtime.record_gpu_board_power_w(watts); + } runtime.observe_thermal_temperature( eff.max_temp_c, eff.throttle_workgroups, @@ -976,6 +1087,146 @@ mod tests { assert_eq!(runtime.gpu_temp_c(), Some(67.5)); } + /// Run the whole production path against the real card and print what it + /// publishes: sensor detection, the monitor thread, the freshness window, + /// and the snapshot the panel reads. Ignored by default because it needs an + /// AMD GPU present; it is the verification step for the power sensor and is + /// meant to be run twice, once with the card idle and once under load, so + /// the published watts can be seen to move. + /// + /// cargo test --release --features ocl -p app --lib \ + /// report_real_board_power -- --ignored --nocapture + #[test] + #[ignore = "needs a real AMD GPU; run by hand at two load levels"] + fn report_real_board_power() { + use crate::efficiency::{EfficiencyConf, EfficiencyMode}; + + let eff = EfficiencyConf { + mode: EfficiencyMode::Profit, + power_cost_kwh: 0.25, + gpu_watts: 350.0, + cpu_watts_per_thread: 8.0, + hac_price: 0.0, + dynamic_supervene: false, + supervene_min: 0, + supervene_max: 0, + oom_fallback: true, + // 0 = reporting-only, which is the shipped default and the path a + // panel operator is actually on. + max_temp_c: 0, + throttle_workgroups: 0, + thermal_file: String::new(), + idle_start_hour: 255, + idle_end_hour: 255, + pause_if_unprofitable: false, + benchmark_seconds: 0, + benchmark_fine_sweep: false, + thermal_gpu_index: 0, + stats_file: String::new(), + }; + let runtime = MiningRuntimeState::new(48, 0); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + start_thermal_monitor( + &runtime, + &eff, + 48, + &[(crate::gpu_arch::GpuVendor::Amd, 0)], + Some(stop.clone()), + ); + + // The reporting-only monitor detects on its own thread, so wait for the + // first reading rather than assuming one is there. + let deadline = Instant::now() + Duration::from_secs(20); + while runtime.gpu_board_power_w().is_none() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + let measured = runtime.gpu_board_power_w(); + let stats = crate::mining_stats::build_mining_stats( + 1_000_000.0, + 0.5, + 0.01, + &eff, + "amd_profit", + 0, + 768_000, + false, + 48, + 48, + 0, + 48, + 1_000_000.0, + 0.0, + runtime.gpu_temp_c(), + measured, + ); + println!( + "measured board power = {:?} W, temperature = {:?} C\n\ + published: watts = {:.1} ({}), kH/J = {:.3}, cost = {:.4} EUR/d", + stats.gpu_board_power_w, + stats.gpu_temp_c, + stats.watts, + if stats.watts_measured { + "measured" + } else { + "estimate" + }, + stats.kh_per_j, + stats.daily_cost_eur, + ); + stop.store(true, Relaxed); + assert!( + measured.is_some(), + "no AMD card reported board power; this test needs one" + ); + } + + #[test] + fn a_runtime_that_never_read_a_power_sensor_reports_no_power() { + let runtime = MiningRuntimeState::new(64, 0); + assert_eq!(runtime.gpu_board_power_w(), None); + // Zero is the dangerous one. A card that is drawing power never reads + // zero, so a zero is an unsupported sensor, and publishing it would put + // free electricity into the operator's cost figure. + for impossible in [0.0, -5.0, f32::NAN, f32::INFINITY, 100_000.0] { + runtime.record_gpu_board_power_w(impossible); + assert_eq!(runtime.gpu_board_power_w(), None, "{impossible}"); + } + runtime.record_gpu_board_power_w(256.5); + assert_eq!(runtime.gpu_board_power_w(), Some(256.5)); + } + + #[test] + fn a_power_sensor_that_went_silent_stops_being_quoted() { + // Same staleness rule as the temperature, and it shares the temperature's + // window because both are read in one monitor pass. + let runtime = MiningRuntimeState::new(64, 0); + runtime.record_gpu_board_power_w(240.0); + assert_eq!(runtime.gpu_board_power_w(), Some(240.0)); + let taken_at = runtime.gpu_power_unix_ms.load(Relaxed); + let window = runtime.gpu_temp_fresh_ms.load(Relaxed); + runtime + .gpu_power_unix_ms + .store(taken_at - window - 1, Relaxed); + assert_eq!(runtime.gpu_board_power_w(), None); + } + + #[test] + fn a_rig_total_above_one_board_is_a_reading_not_an_error() { + // Four cards at 256 W. The per-board window lives at the sensor; what + // reaches the runtime is already a sum and must survive. + let runtime = MiningRuntimeState::new(64, 0); + runtime.record_gpu_board_power_w(1_024.0); + assert_eq!(runtime.gpu_board_power_w(), Some(1_024.0)); + } + + #[test] + fn one_card_without_a_power_sensor_voids_the_whole_rig_total() { + // A partial sum is not the rig's draw; it is a fraction of it wearing + // the same label, and it would understate the bill. `None` sends the + // caller back to its configured estimate, which at least says what it is. + assert_eq!(total_board_power_reading(&[]), None); + } + #[test] fn a_sensor_that_went_silent_stops_being_quoted() { let runtime = MiningRuntimeState::new(64, 0); @@ -995,15 +1246,19 @@ mod tests { fn freshness_window_follows_the_poll_cadence() { // One constant that can contradict the interval is what let a 15s window // expire on every single sample of a slower loop. - assert_eq!(temp_freshness_ms(ThermalCadence::GUARDED, 1), 20_000); + // + // One guarded GPU: a worst-case pass is 5s (a temperature probe and a + // power probe, 2.5s each), the gap to the next published value is + // 5 + 2.5 + 5 = 12.5s, and the window is two of those plus 5s of slack. + assert_eq!(temp_freshness_ms(ThermalCadence::GUARDED, 1), 30_000); assert!( temp_freshness_ms(ThermalCadence::REPORTING, 1) > 2 * THERMAL_REPORTING_POLL_INTERVAL.as_millis() as u64 ); let runtime = MiningRuntimeState::new(64, 0); - assert_eq!(runtime.gpu_temp_fresh_ms.load(Relaxed), 20_000); + assert_eq!(runtime.gpu_temp_fresh_ms.load(Relaxed), 30_000); runtime.set_temp_freshness_window(ThermalCadence::REPORTING, 1); - assert_eq!(runtime.gpu_temp_fresh_ms.load(Relaxed), 70_000); + assert_eq!(runtime.gpu_temp_fresh_ms.load(Relaxed), 75_000); } #[test] @@ -1024,7 +1279,10 @@ mod tests { ); // And it is derived, not guessed: the window follows the sensor count // because the pass does. - assert!(temp_freshness_ms(ThermalCadence::GUARDED, 8) > temp_freshness_ms(ThermalCadence::GUARDED, 1)); + assert!( + temp_freshness_ms(ThermalCadence::GUARDED, 8) + > temp_freshness_ms(ThermalCadence::GUARDED, 1) + ); let runtime = MiningRuntimeState::new(64, 0); runtime.set_temp_freshness_window(ThermalCadence::GUARDED, 8); @@ -1086,12 +1344,45 @@ mod tests { fn a_worst_case_pass_is_counted_per_gpu() { assert_eq!(worst_case_pass(0), SENSOR_PASS_WORST_CASE_PER_GPU); assert_eq!(worst_case_pass(1), SENSOR_PASS_WORST_CASE_PER_GPU); - assert_eq!(worst_case_pass(8), Duration::from_secs(20)); + assert_eq!(worst_case_pass(8), Duration::from_secs(40)); // Nothing here may overflow into a tiny window on an absurd rig. - assert!(worst_case_pass(usize::MAX) >= Duration::from_secs(20)); + assert!(worst_case_pass(usize::MAX) >= Duration::from_secs(40)); assert!(temp_freshness_ms(ThermalCadence::GUARDED, usize::MAX) >= 90_000); } + /// The pass bound covers BOTH questions a pass asks a card. + /// + /// Wiring `nvidia-smi --query-gpu=power.draw` turned the power half of a + /// pass from a free `None` into a second bounded subprocess per NVIDIA card. + /// A bound still sized for one probe would be half the truth, and the + /// freshness window derived from it would blank a gauge that the rig is + /// still feeding. + #[test] + fn the_pass_bound_pays_for_the_power_probe_as_well_as_the_temperature_probe() { + // One bounded probe: the 2s command timeout plus the bounded kill. + let one_probe = Duration::from_millis(2_500); + assert_eq!( + SENSOR_PASS_WORST_CASE_PER_GPU, + one_probe * 2, + "a pass asks each card for its temperature and for its watts" + ); + + // Eight NVIDIA cards whose every probe times out: sixteen probes. + let sick_nvidia_pass = one_probe * 16; + assert!( + worst_case_pass(8) >= sick_nvidia_pass, + "the bound must not be exceeded by the case it exists to bound" + ); + + // And the window built on it survives two of those passes, which is the + // property the whole constant exists for. + let window = temp_freshness_ms(ThermalCadence::GUARDED, 8); + assert!( + window > 2 * sick_nvidia_pass.as_millis() as u64, + "a guarded window of {window}ms must survive two worst-case passes" + ); + } + #[test] fn reporting_only_mode_never_pauses_or_caps_anything() { // max_temp_c = 0 is the panel's own default: the guard is off. This @@ -1324,8 +1615,7 @@ mod tests { Some(hot_stop.clone()), ); let hot_at_return = hot_runtime.gpu_temp_c(); - let hot_gated_at_return = - crate::efficiency::mining_is_gated(&hot_runtime, &hot_efficiency); + let hot_gated_at_return = crate::efficiency::mining_is_gated(&hot_runtime, &hot_efficiency); let hot_cap_at_return = hot_runtime.thermal_workgroups_cap(); hot_stop.store(true, Relaxed); @@ -1518,9 +1808,7 @@ mod tests { let hot_cap = runtime.thermal_workgroups_cap(); std::fs::write(&path, "63.5\n").expect("cool the test sensor down"); - let resumed = wait_for(Duration::from_secs(30), || { - !runtime.thermal_pause_active() - }); + let resumed = wait_for(Duration::from_secs(30), || !runtime.thermal_pause_active()); let cool_gated = crate::efficiency::mining_is_gated(&runtime, &efficiency); let cool_cap = runtime.thermal_workgroups_cap(); @@ -1550,7 +1838,11 @@ mod tests { worker loop to hash at 95.0C" ); assert_eq!(hot_temp, Some(95.0), "the 95.0C sample was never published"); - assert_eq!(hot_cap, Some(32), "no conservative work_groups cap at 95.0C"); + assert_eq!( + hot_cap, + Some(32), + "no conservative work_groups cap at 95.0C" + ); assert!( resumed, diff --git a/app/src/mining_stats.rs b/app/src/mining_stats.rs index ccc65416..7cf84f2e 100644 --- a/app/src/mining_stats.rs +++ b/app/src/mining_stats.rs @@ -13,6 +13,13 @@ pub struct MiningStatsSnapshot { pub status: String, pub hashrate_hps: f64, pub hashrate_display: String, + /// Total draw of the whole rig: the GPUs plus the CPU threads assisting them. + /// + /// The GPU half is a real measurement wherever `gpu_board_power_w` is + /// present and the configured `gpu_watts` estimate everywhere else. The CPU + /// half is always an estimate, because nothing here measures CPU package + /// power. `watts_measured` says which of those two this number is, and it is + /// the only honest way to read this field. pub watts: f64, pub kh_per_j: f64, pub hac_per_day: f64, @@ -56,6 +63,29 @@ pub struct MiningStatsSnapshot { /// is the one thing this field must never cause. #[serde(default)] pub gpu_temp_c: Option, + /// Total board power the rig's GPUs are really drawing, in watts, summed + /// across every card, as read from the card's own sensor. + /// + /// `None` where nothing measured it, under exactly the rule `gpu_temp_c` + /// follows: no sensor, no field, never a zero. It matters more here than it + /// does for temperature. A gauge reading 0 C looks like a cold card; a cost + /// figure built on 0 W looks like free electricity, and it would flow + /// straight into kH/J, the daily cost, and the profitability pause. + /// + /// This is BOARD power, the whole card including memory and VRM losses, + /// which is the number the electricity meter sees. It is not the die-only + /// ASIC figure, which reads tens of watts lower. + #[serde(default)] + pub gpu_board_power_w: Option, + /// Whether `watts` is a measurement rather than an estimate, end to end. + /// + /// True only when every part of the total came from a sensor: the GPUs + /// measured, and no CPU-assist threads whose draw could only ever be + /// estimated. A rig with a measured card AND two assist threads publishes + /// `gpu_board_power_w` and leaves this false, because two thirds of a truth + /// is not a measurement and the panel must not label it as one. + #[serde(default)] + pub watts_measured: bool, pub active_cpu_threads: u32, pub paused_unprofitable: bool, pub mining_kind: String, @@ -122,6 +152,7 @@ pub fn emit_from_batch_aggregate( agg.gpu_hashrate, agg.cpu_hashrate, runtime.gpu_temp_c(), + runtime.gpu_board_power_w(), ) }; write_mining_stats(stats_path, &stats); @@ -144,15 +175,23 @@ pub fn build_mining_stats( gpu_hashrate_hps: f64, cpu_hashrate_hps: f64, gpu_temp_c: Option, + gpu_board_power_w: Option, ) -> MiningStatsSnapshot { - let gpu_w = eff.estimate_gpu_watts(profile); - let watts = gpu_w + active_cpu as f64 * eff.cpu_watts_per_thread; + // A measured board draw replaces the configured guess wherever one exists, + // and everything below is computed from it: efficiency, daily cost, and the + // margin the profit pause acts on. That is the whole point of measuring. + let measured_gpu_w = sensor_board_power_w(gpu_board_power_w); + let gpu_w = measured_gpu_w + .map(f64::from) + .unwrap_or_else(|| eff.estimate_gpu_watts(profile)); + let cpu_w = active_cpu as f64 * eff.cpu_watts_per_thread; + let watts = gpu_w + cpu_w; let kh_per_j = if watts > 0.0 { hashrate / watts / 1000.0 } else { 0.0 }; - let daily_cost = eff.daily_power_cost_eur(profile, active_cpu); + let daily_cost = watts * 24.0 / 1000.0 * eff.power_cost_kwh; let daily_revenue = hac_per_day * eff.hac_price; let daily_net = daily_revenue - daily_cost; let status = if paused { @@ -183,6 +222,17 @@ pub fn build_mining_stats( cpu_hashrate_hps, gpu_hashrate_display: rates_to_show(gpu_hashrate_hps), gpu_temp_c: sensor_temperature(gpu_temp_c), + gpu_board_power_w: measured_gpu_w, + // The CPU term is an estimate, so the total is only a measurement when + // there is no CPU term at all. + // + // Tested on `active_cpu`, not on `cpu_w`. `cpu_w` is + // `active_cpu * cpu_watts_per_thread`, so an operator who sets + // `cpu_watts_per_thread = 0` while CPU assist threads are running makes + // it zero with the CPU draw simply missing from the total, and the label + // would then call that total "measured". The thread count cannot lie the + // same way. + watts_measured: measured_gpu_w.is_some() && active_cpu == 0, active_cpu_threads: active_cpu, paused_unprofitable: paused, mining_kind: "hac".to_string(), @@ -244,8 +294,12 @@ pub fn build_diamond_mining_stats( cpu_hashrate_hps, gpu_hashrate_display: rates_to_show(0.0), // HACD mines on the CPU through the full node. There is no GPU under - // this snapshot, so there is no GPU temperature to report. + // this snapshot, so there is no GPU temperature and no GPU board power + // to report, and the CPU watts here are the configured per-thread + // estimate rather than anything measured. gpu_temp_c: None, + gpu_board_power_w: None, + watts_measured: false, active_cpu_threads: active_cpu, paused_unprofitable: paused, mining_kind: "hacd".to_string(), @@ -262,6 +316,19 @@ fn sensor_temperature(temp_c: Option) -> Option { temp_c.filter(|c| c.is_finite() && *c > 0.0 && *c < 120.0) } +/// The last gate a board-power reading passes before it is published, and the +/// same gate `MiningRuntimeState` applies before it stores one. +/// +/// The window is deliberately loose at the top. This is a RIG total, the sum +/// over every card, and the tight per-board window (a graphics board draws under +/// 1000 W) has already been applied at the sensor by `gpu_temp_adl`. A ceiling +/// here that assumed one card would throw away the correct answer on a four-card +/// rig. What is left to reject is the shapes that are not measurements at all: +/// not finite, not positive, or a number no electrical installation could feed. +pub(crate) fn sensor_board_power_w(watts: Option) -> Option { + watts.filter(|w| w.is_finite() && *w > 0.0 && *w < 100_000.0) +} + pub fn write_mining_stats(path: &str, stats: &MiningStatsSnapshot) { if path.is_empty() { return; @@ -348,13 +415,21 @@ mod tests { } fn hac_stats(gpu_temp_c: Option) -> MiningStatsSnapshot { + hac_stats_with_power(gpu_temp_c, None, 2) + } + + fn hac_stats_with_power( + gpu_temp_c: Option, + gpu_board_power_w: Option, + active_cpu: u32, + ) -> MiningStatsSnapshot { build_mining_stats( 1_000_000.0, 0.5, 0.01, &efficiency(), "amd_profit", - 2, + active_cpu, 765_432, false, 1_536, @@ -364,6 +439,7 @@ mod tests { 900_000.0, 100_000.0, gpu_temp_c, + gpu_board_power_w, ) } @@ -398,6 +474,145 @@ mod tests { } } + #[test] + fn the_worker_carries_a_recorded_power_all_the_way_into_the_stats_file() { + // The one link the hardware test cannot reach: what the monitor recorded + // on the runtime has to arrive in the JSON the panel actually reads. + let runtime = MiningRuntimeState::new(48, 0); + runtime.record_gpu_board_power_w(291.0); + let path = std::env::temp_dir().join(format!( + "hacash-stats-power-{}-{}.json", + std::process::id(), + unix_ms_now() + )); + let agg = BatchAggregate { + hashrate: 18_830_000.0, + hac_per_day: 0.5, + network_pct: 0.01, + height: 768_000, + gpu_hashrate: 18_830_000.0, + cpu_hashrate: 0.0, + paused: false, + }; + emit_from_batch_aggregate( + &agg, + &efficiency(), + "amd_profit", + 0, + 48, + &runtime, + "hac", + 0, + "", + path.to_str().unwrap(), + ); + let saved: MiningStatsSnapshot = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.gpu_board_power_w, Some(291.0)); + assert!(saved.watts_measured); + assert!((saved.watts - 291.0).abs() < 0.000_001); + // The real measured pair from the RX 9070 XT sweep: 18.83 MH/s at 291 W + // is 64.7 kH/J. The 246 W estimate would have claimed 76.5. + assert!((saved.kh_per_j - 64.71).abs() < 0.01, "{}", saved.kh_per_j); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn with_no_power_sensor_the_watts_are_the_configured_estimate_and_say_so() { + // gpu_watts = 300 in the ini, amd_profit factor, plus 2 CPU threads at + // 8 W. Whatever that arithmetic comes to, it is a guess, and the + // snapshot has to admit it. + let stats = hac_stats(Some(60.0)); + assert_eq!(stats.gpu_board_power_w, None); + assert!(!stats.watts_measured); + let estimate = efficiency().estimate_gpu_watts("amd_profit") + 2.0 * 8.0; + assert!((stats.watts - estimate).abs() < 0.000_001); + } + + #[test] + fn a_measured_board_draw_replaces_the_estimate_everywhere_it_was_used() { + // The card really measured 256 W. The 300 W ini guess must not survive + // anywhere downstream: not in watts, not in kH/J, not in the daily cost. + let measured = hac_stats_with_power(Some(60.0), Some(256.0), 0); + let guessed = hac_stats_with_power(Some(60.0), None, 0); + assert_eq!(measured.gpu_board_power_w, Some(256.0)); + assert!((measured.watts - 256.0).abs() < 0.000_001); + assert!(measured.watts_measured); + assert!(!guessed.watts_measured); + + // The guess on this config is 300 W of board times the 0.82 profit + // factor, which is 246 W: it UNDERSTATES the card by 10 W. So the + // measurement makes the rig look worse, not better, and every derived + // figure has to move that way. Publishing a flattering guess is exactly + // the failure being removed here. + assert!( + (guessed.watts - 246.0).abs() < 0.000_001, + "{}", + guessed.watts + ); + assert!(measured.watts > guessed.watts); + assert!(measured.kh_per_j < guessed.kh_per_j); + assert!(measured.daily_cost_eur > guessed.daily_cost_eur); + assert!((measured.kh_per_j - 1_000_000.0 / 256.0 / 1000.0).abs() < 0.000_001); + // 256 W for 24 h at 0.25 EUR/kWh. + assert!((measured.daily_cost_eur - 256.0 * 24.0 / 1000.0 * 0.25).abs() < 0.000_001); + assert!( + (measured.daily_net_eur - (measured.daily_revenue_eur - measured.daily_cost_eur)).abs() + < 0.000_001 + ); + } + + #[test] + fn a_measured_gpu_plus_an_estimated_cpu_is_not_called_a_measurement() { + // Two CPU assist threads at a per-thread guess. The GPU figure is still + // published, because it is real, but the TOTAL is part guess and the + // panel must not be allowed to label it "measured". + let stats = hac_stats_with_power(Some(60.0), Some(256.0), 2); + assert_eq!(stats.gpu_board_power_w, Some(256.0)); + assert!(!stats.watts_measured); + assert!((stats.watts - (256.0 + 16.0)).abs() < 0.000_001); + } + + #[test] + fn a_reading_no_board_sensor_could_produce_is_dropped_not_published() { + // Zero is the one that matters: it is what an unsupported ADL slot + // holds, and published as watts it turns into free electricity, an + // infinite kH/J and a rig that can never be paused for cost. + for impossible in [0.0, -1.0, f32::NAN, f32::INFINITY, 100_000.0] { + let stats = hac_stats_with_power(Some(60.0), Some(impossible), 0); + assert_eq!( + stats.gpu_board_power_w, None, + "{impossible} is not a board draw" + ); + assert!(!stats.watts_measured); + assert!( + stats.watts > 0.0, + "a rejected reading must fall back to the estimate, not to zero" + ); + } + } + + #[test] + fn a_multi_card_rig_total_is_above_one_board_and_still_accepted() { + // The per-board window (under 1000 W) lives at the sensor. What arrives + // here is a SUM over cards, so a four-card rig at 1024 W is a correct + // reading and rejecting it would be the bug. + let stats = hac_stats_with_power(Some(60.0), Some(1_024.0), 0); + assert_eq!(stats.gpu_board_power_w, Some(1_024.0)); + assert!((stats.watts - 1_024.0).abs() < 0.000_001); + } + + #[test] + fn a_measured_power_survives_the_json_and_an_absent_one_stays_absent() { + for measured in [None, Some(256.0f32)] { + let stats = hac_stats_with_power(Some(60.0), measured, 0); + let json = serde_json::to_string(&stats).unwrap(); + let read: MiningStatsSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(read.gpu_board_power_w, measured); + assert_eq!(read.watts_measured, stats.watts_measured); + } + } + #[test] fn a_stats_file_written_before_the_sensor_existed_still_reads_back() { // Older workers wrote no temperature field at all. That file must load @@ -411,6 +626,11 @@ mod tests { let read: MiningStatsSnapshot = serde_json::from_str(legacy).unwrap(); assert_eq!(read.gpu_temp_c, None); assert_eq!(read.height, 7); + // The same file predates the power sensor too, and an installed worker + // still writes it. It must load as "not measured" rather than as a rig + // drawing zero watts. + assert_eq!(read.gpu_board_power_w, None); + assert!(!read.watts_measured); } #[test] @@ -434,6 +654,7 @@ mod tests { 900_000.0, 100_000.0, None, + None, ); assert_eq!(stats.oom_allowed_work_groups, stats.configured_work_groups); assert_eq!(stats.thermal_cap_work_groups, 0); diff --git a/app/src/node_api.rs b/app/src/node_api.rs index b0368cc0..7185b612 100644 --- a/app/src/node_api.rs +++ b/app/src/node_api.rs @@ -3,9 +3,15 @@ use std::sync::Arc; use basis::component::TX_ACTIONS_MAX; use basis::config::EngineConf; use basis::interface::{ApiExecCtx, ApiRequest, ApiResponse, ApiRoute, ApiService}; +use field::*; use protocol::setup::ProtocolSetup; use serde_json::{Value, json}; +use crate::hpay_channel_exit::{ + MAINNET_MIN_SAFE_HEIGHT, channel_snapshot as hpay_channel_exit_snapshot, + evidence as hpay_channel_exit_evidence, +}; +use crate::hpay_channel_registry::channel_snapshot as hpay_channel_registry_snapshot; use crate::{HACASH_NODE_BUILD_TIME, HACASH_NODE_VERSION}; const CAPABILITIES_API_VERSION: u32 = 1; @@ -20,6 +26,11 @@ const P2SH_ACTION_KIND: u16 = 46; const REQ_SIGN_LIST_ACTION_KIND: u16 = 0x0414; const TYPE4_TRANSACTION_TYPE: u8 = 4; const ACCOUNT_ABSTRACTION_ACTION_KINDS: &[u16] = &[40, 41, 44, P2SH_ACTION_KIND]; +const LOCAL_PILOT_NETWORK_KIND: &str = "local_pilot_v1"; +const LOCAL_PILOT_PROFILE_ID: &str = "hpay-local-pilot-chain-v1"; +const MAINNET_NETWORK_KIND: &str = "mainnet"; +const MAINNET_PROFILE_ID: &str = "hacash-mainnet"; +const TRANSACTION_FORMAT_VERSION: u64 = 2; #[derive(Default)] struct NodeCapabilitiesService; @@ -30,7 +41,140 @@ impl ApiService for NodeCapabilitiesService { } fn routes(&self) -> Vec { - vec![ApiRoute::get("/query/capabilities", query_capabilities)] + vec![ + ApiRoute::get("/query/capabilities", query_capabilities), + ApiRoute::get("/query/hpay/channel-exit", query_hpay_channel_exit), + ApiRoute::get("/query/hpay/channel-registry", query_hpay_channel_registry), + ApiRoute::post( + "/submit/transaction/hpay-bound", + submit_transaction_hpay_bound, + ), + ] + } +} + +fn submit_transaction_hpay_bound(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + let expected_chain_id = match required_bound_chain_id(&req) { + Ok(value) => value, + Err(error) => return ApiResponse::json(json!({"ret": 1, "err": error}).to_string()), + }; + let expected_instance_id = match required_bound_network_instance_id(&req) { + Ok(value) => value, + Err(error) => return ApiResponse::json(json!({"ret": 1, "err": error}).to_string()), + }; + mint::api::submit_transaction_with_pre_admission_check(ctx, req, move |ctx| { + validate_hpay_bound_submit(ctx, expected_chain_id, &expected_instance_id) + }) +} + +fn validate_hpay_bound_submit( + ctx: &ApiExecCtx, + expected_chain_id: u32, + expected_instance_id: &str, +) -> Result<(), String> { + let actual = current_network_instance(ctx).ok_or_else(|| { + "HPAY bound submit is unavailable until canonical block 1 is readable".to_owned() + })?; + validate_bound_network_identity(expected_chain_id, expected_instance_id, &actual) +} + +fn validate_bound_network_identity( + expected_chain_id: u32, + expected_instance_id: &str, + actual: &CurrentNetworkInstance, +) -> Result<(), String> { + if expected_chain_id != actual.chain_id { + return Err("HPAY bound submit chain_id mismatch".to_owned()); + } + if expected_instance_id != actual.instance_id { + return Err("HPAY bound submit network_instance_id mismatch".to_owned()); + } + Ok(()) +} + +fn required_bound_chain_id(req: &ApiRequest) -> Result { + let value = req + .query("chain_id") + .ok_or_else(|| "HPAY bound submit requires chain_id".to_owned())?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("HPAY bound submit chain_id must be canonical decimal u32".to_owned()); + } + let parsed = value + .parse::() + .map_err(|_| "HPAY bound submit chain_id must be canonical decimal u32".to_owned())?; + if parsed.to_string() != value { + return Err("HPAY bound submit chain_id must be canonical decimal u32".to_owned()); + } + Ok(parsed) +} + +fn required_bound_network_instance_id(req: &ApiRequest) -> Result { + let value = req + .query("network_instance_id") + .ok_or_else(|| "HPAY bound submit requires network_instance_id".to_owned())?; + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err( + "HPAY bound submit network_instance_id must be lowercase SHA-256 hex".to_owned(), + ); + } + Ok(value.to_owned()) +} + +fn query_hpay_channel_exit(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + let contract = req.query("contract").unwrap_or(""); + let deployment_tx_hash = req.query("deployment_tx_hash").unwrap_or(""); + let deployment_height = match req + .query("deployment_height") + .and_then(|value| value.parse::().ok()) + { + Some(height) => height, + None => { + return ApiResponse::json( + json!({"ret": 1, "err": "HPAY HVM deployment height is invalid"}).to_string(), + ); + } + }; + match hpay_channel_exit_snapshot(ctx, contract, deployment_tx_hash, deployment_height) { + Ok(value) => ApiResponse::json(value.to_string()), + Err(error) => ApiResponse::json(json!({"ret": 1, "err": error}).to_string()), + } +} + +fn query_hpay_channel_registry(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + let contract = req.query("contract").unwrap_or(""); + let deployment_tx_hash = req.query("deployment_tx_hash").unwrap_or(""); + let left = req.query("left").unwrap_or(""); + let deployment_height = match req + .query("deployment_height") + .and_then(|value| value.parse::().ok()) + { + Some(height) => height, + None => { + return ApiResponse::json( + json!({"ret": 1, "err": "HPAY HVM registry deployment height is invalid"}) + .to_string(), + ); + } + }; + let Some(network) = current_network_instance(ctx) else { + return ApiResponse::json( + json!({"ret": 1, "err": "HPAY HVM registry requires canonical block 1"}).to_string(), + ); + }; + match hpay_channel_registry_snapshot( + ctx, + contract, + deployment_tx_hash, + deployment_height, + left, + &network.instance_id, + ) { + Ok(value) => ApiResponse::json(value.to_string()), + Err(error) => ApiResponse::json(json!({"ret": 1, "err": error}).to_string()), } } @@ -40,9 +184,110 @@ pub fn service() -> Arc { fn query_capabilities(ctx: &ApiExecCtx, _req: ApiRequest) -> ApiResponse { let height = ctx.engine.latest_block().height().uint(); + let tip_timestamp_unix = ctx.engine.latest_block().timestamp().uint(); + let observed_unix = sys::curtimes(); let config = ctx.engine.config(); let setup = protocol::setup::current_setup(); - ApiResponse::json(build_capabilities(config, setup.as_ref(), height).to_string()) + let block_one_hash = canonical_block_one_hash(ctx); + let funding_confirmed = confirmed_pilot_funding(ctx); + let channel_exit_evidence = hpay_channel_exit_evidence(Some(ctx)); + ApiResponse::json( + build_capabilities_with_tip_and_exit_evidence( + config, + setup.as_ref(), + height, + block_one_hash.as_deref(), + funding_confirmed, + tip_timestamp_unix, + observed_unix, + channel_exit_evidence, + ) + .to_string(), + ) +} + +fn canonical_block_one_hash(ctx: &ApiExecCtx) -> Option { + let (stored_hash, bytes) = ctx + .engine + .store() + .block_data_by_height(&BlockHeight::from(1))?; + let package = protocol::block::build_block_package(bytes).ok()?; + if package.block().height().uint() != 1 || package.hash() != stored_hash { + return None; + } + Some(stored_hash.to_hex()) +} + +fn confirmed_pilot_funding(ctx: &ApiExecCtx) -> bool { + let Some(address) = ctx.engine.config().pilot_funding_address.as_ref() else { + return false; + }; + let state = ctx.engine.state(); + let core = protocol::state::CoreStateRead::wrap(state.as_ref().as_ref()); + core.balance(address) + .unwrap_or_default() + .hacash + .is_positive() +} + +fn push_identity_field(bytes: &mut Vec, value: &str) { + bytes.extend_from_slice(&(value.len() as u64).to_be_bytes()); + bytes.extend_from_slice(value.as_bytes()); +} + +fn network_instance_id( + network_kind: &str, + chain_id: u32, + mainnet: bool, + block_one_hash: &str, + node_profile_id: &str, +) -> String { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"HPAY/NETWORK-INSTANCE/V1"); + push_identity_field(&mut bytes, network_kind); + bytes.extend_from_slice(&chain_id.to_be_bytes()); + bytes.push(u8::from(mainnet)); + push_identity_field(&mut bytes, block_one_hash); + push_identity_field(&mut bytes, node_profile_id); + bytes.extend_from_slice(&TRANSACTION_FORMAT_VERSION.to_be_bytes()); + hex::encode(sys::sha2(&bytes)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CurrentNetworkInstance { + chain_id: u32, + instance_id: String, +} + +fn current_network_instance(ctx: &ApiExecCtx) -> Option { + let config = ctx.engine.config(); + let chain_id = config.chain_id; + let mainnet = chain_id == protocol::upgrade::MAINNET_CHAIN_ID; + let network_kind = if mainnet { + MAINNET_NETWORK_KIND + } else if config.network_kind == LOCAL_PILOT_NETWORK_KIND + && config.node_profile_id == LOCAL_PILOT_PROFILE_ID + { + LOCAL_PILOT_NETWORK_KIND + } else { + "unidentified_non_mainnet" + }; + let node_profile_id = if mainnet { + MAINNET_PROFILE_ID + } else { + config.node_profile_id.as_str() + }; + let block_one_hash = canonical_block_one_hash(ctx)?; + Some(CurrentNetworkInstance { + chain_id, + instance_id: network_instance_id( + network_kind, + chain_id, + mainnet, + &block_one_hash, + node_profile_id, + ), + }) } fn enabled_transaction_types( @@ -71,7 +316,59 @@ fn has_all_action_kinds(setup: &ProtocolSetup, kinds: &[u16]) -> bool { kinds.iter().all(|kind| setup.has_action_kind(*kind)) } -fn build_capabilities(config: &EngineConf, setup: &ProtocolSetup, height: u64) -> Value { +#[cfg(test)] +fn build_capabilities( + config: &EngineConf, + setup: &ProtocolSetup, + height: u64, + block_one_hash: Option<&str>, + funding_confirmed: bool, +) -> Value { + build_capabilities_with_tip( + config, + setup, + height, + block_one_hash, + funding_confirmed, + 0, + 0, + ) +} + +#[cfg(test)] +fn build_capabilities_with_tip( + config: &EngineConf, + setup: &ProtocolSetup, + height: u64, + block_one_hash: Option<&str>, + funding_confirmed: bool, + tip_timestamp_unix: u64, + observed_unix: u64, +) -> Value { + build_capabilities_with_tip_and_exit_evidence( + config, + setup, + height, + block_one_hash, + funding_confirmed, + tip_timestamp_unix, + observed_unix, + hpay_channel_exit_evidence(None), + ) +} + +fn build_capabilities_with_tip_and_exit_evidence( + config: &EngineConf, + setup: &ProtocolSetup, + height: u64, + block_one_hash: Option<&str>, + funding_confirmed: bool, + tip_timestamp_unix: u64, + observed_unix: u64, + channel_exit_evidence: Value, +) -> Value { + const MAX_TIP_AGE_SECONDS: u64 = 3_600; + const MAX_FUTURE_SKEW_SECONDS: u64 = 120; let chain_id = config.chain_id; let next_height = height.saturating_add(1); let registered_transactions = setup.registered_tx_types(); @@ -87,6 +384,47 @@ fn build_capabilities(config: &EngineConf, setup: &ProtocolSetup, height: u64) - TYPE4_TRANSACTION_TYPE, ) .is_ok(); + let network_kind = if mainnet { + MAINNET_NETWORK_KIND + } else if config.network_kind == LOCAL_PILOT_NETWORK_KIND + && config.node_profile_id == LOCAL_PILOT_PROFILE_ID + { + LOCAL_PILOT_NETWORK_KIND + } else { + "unidentified_non_mainnet" + }; + let node_profile_id = if mainnet { + MAINNET_PROFILE_ID + } else { + config.node_profile_id.as_str() + }; + let block_one_hash = block_one_hash.filter(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }); + let instance_id = block_one_hash + .map(|hash| network_instance_id(network_kind, chain_id, mainnet, hash, node_profile_id)); + let tip_age_seconds = observed_unix.saturating_sub(tip_timestamp_unix); + let tip_fresh = height > 0 + && tip_timestamp_unix > 0 + && tip_timestamp_unix <= observed_unix.saturating_add(MAX_FUTURE_SKEW_SECONDS) + && tip_age_seconds <= MAX_TIP_AGE_SECONDS; + let local_pilot_ready = !mainnet + && network_kind == LOCAL_PILOT_NETWORK_KIND + && height >= 2 + && block_one_hash.is_some() + && funding_confirmed; + let mainnet_ready = mainnet + && height >= MAINNET_MIN_SAFE_HEIGHT + && block_one_hash.is_some() + && tip_fresh + && enabled_transactions.contains(&2) + && [1_u16, 2, 3, 14] + .iter() + .all(|kind| enabled_actions.contains(kind)); + let transaction_ready = local_pilot_ready || mainnet_ready; // These flags describe codecs/runtime actually wired into this node process. // Chain-height availability remains separately represented by `actions.enabled`. @@ -96,6 +434,12 @@ fn build_capabilities(config: &EngineConf, setup: &ProtocolSetup, height: u64) - let account_abstraction = hvm && has_all_action_kinds(setup, ACCOUNT_ABSTRACTION_ACTION_KINDS); let intent = contract_runtime; let contract_state_leasing = contract_runtime; + // A verified deployed HVM artifact is necessary but not sufficient for + // the existing native ChannelPay settlement profile. The wallet, Hub, + // bill codec, funding path and recovery/watchtower must first bind every + // channel to this exact contract profile. Never auto-enable the native + // capability from deployment evidence alone. + let channel_unilateral_exit = false; json!({ "ret": 0, @@ -111,6 +455,24 @@ fn build_capabilities(config: &EngineConf, setup: &ProtocolSetup, height: u64) - "next_height": next_height, "mainnet": mainnet, }, + "network": { + "kind": network_kind, + "node_profile_id": node_profile_id, + "block_1_available": block_one_hash.is_some(), + "block_1_hash": block_one_hash, + "instance_id": instance_id, + "funding_confirmed": funding_confirmed, + "transaction_ready": transaction_ready, + "current_height": height, + "transaction_format_version": TRANSACTION_FORMAT_VERSION, + }, + "sync": { + "tip_timestamp_unix": tip_timestamp_unix, + "observed_unix": observed_unix, + "tip_age_seconds": tip_age_seconds, + "max_tip_age_seconds": MAX_TIP_AGE_SECONDS, + "fresh": tip_fresh, + }, "istanbul": { "activation_height": protocol::upgrade::ONLINE_OPEN_HEIGHT, "evaluation_height": next_height, @@ -140,8 +502,26 @@ fn build_capabilities(config: &EngineConf, setup: &ProtocolSetup, height: u64) - "ir_decompilation": false, "req_sign_list": setup.has_action_kind(REQ_SIGN_LIST_ACTION_KIND), "type4_mainnet": type4_mainnet, + // The Istanbul action registry currently has no non-conflicting, + // registered challenge/respond/final-claim path for payment + // channels. Legacy Go action numbers 22, 25 and 26 collide with + // Istanbul TEX/AST actions, so operators must not infer unilateral + // exit support from the persisted challenge fields alone. + "channel_unilateral_exit": channel_unilateral_exit, + "channel_unilateral_exit_evidence": channel_exit_evidence, "exact_unsigned_simulation": false, }, + // Registered by the mint API service in this same fullnode process. + // Clients must not infer write support from a version string. + "api": { + "balance_query": true, + "transaction_submit": true, + "transaction_submit_bound": true, + "transaction_query": true, + "reconciliation_by_tx_hash": true, + "contract_sandbox_query": hvm, + "hpay_channel_registry_query": hvm, + }, "limits": { "max_tx_size": config.max_tx_size, "max_tx_actions": config.max_tx_actions.min(TX_ACTIONS_MAX), @@ -158,6 +538,7 @@ fn build_capabilities(config: &EngineConf, setup: &ProtocolSetup, height: u64) - #[cfg(test)] mod node_capabilities_tests { use super::*; + use crate::hpay_channel_exit::deployment_verified as hpay_channel_exit_deployment_verified; fn test_config(chain_id: u32) -> EngineConf { let mut config = EngineConf::new(&sys::IniObj::new()); @@ -184,26 +565,182 @@ mod node_capabilities_tests { fn mainnet_capabilities_keep_type4_disabled() { let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); let setup = test_setup(); - let value = build_capabilities(&config, &setup, protocol::upgrade::ONLINE_OPEN_HEIGHT); + let value = build_capabilities( + &config, + &setup, + protocol::upgrade::ONLINE_OPEN_HEIGHT, + None, + false, + ); assert_eq!(value["ret"].as_u64(), Some(0)); assert_eq!(value["api_version"].as_u64(), Some(1)); assert_eq!(value["istanbul"]["active"].as_bool(), Some(true)); assert_eq!(value["features"]["type4_mainnet"].as_bool(), Some(false)); + assert_eq!( + value["features"]["channel_unilateral_exit"].as_bool(), + Some(false), + ); + let evidence = &value["features"]["channel_unilateral_exit_evidence"]; + assert_eq!(evidence["schema"], "hpay-hvm-channel-exit-evidence/1"); + assert_eq!(evidence["manifest_valid"], true); + assert_eq!(evidence["contract_name"], "HPAYChannelExitV1"); + assert_eq!( + evidence["bytecode_sha3"], + "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349" + ); + assert_eq!(evidence["storage_key_count"], 18); + assert_eq!(evidence["must_renew_every_storage_key"], true); + assert_eq!(evidence["deployment"]["enabled"], false); + assert_eq!(evidence["deployment"]["independently_verified"], false); + assert_eq!( + evidence["on_chain_verification"]["deployment_tx_confirmed"], + false + ); + assert_eq!( + evidence["on_chain_verification"]["contract_code_matches"], + false + ); + assert_eq!(evidence["deployment_verified"], false); + for name in [ + "balance_query", + "transaction_submit", + "transaction_submit_bound", + "transaction_query", + "reconciliation_by_tx_hash", + ] { + assert_eq!(value["api"][name].as_bool(), Some(true), "API {name}"); + } assert_eq!( value["features"]["exact_unsigned_simulation"].as_bool(), Some(false), ); + assert_eq!( + value["api"]["contract_sandbox_query"].as_bool(), + value["features"]["hvm"].as_bool(), + ); assert!( !numbers(&value["transactions"]["enabled"]).contains(&(TYPE4_TRANSACTION_TYPE as u64)) ); } + #[test] + fn channel_exit_deployment_requires_exact_mainnet_chain_evidence() { + let height = MAINNET_MIN_SAFE_HEIGHT + 100; + let address = Address::create_contract([7_u8; 20]).to_readable(); + let tx_hash = "11".repeat(32); + let code_hash = "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349"; + let deployment = json!({ + "enabled": true, + "contract_address": address, + "deployment_tx_hash": tx_hash, + "deployment_height": height, + "independently_verified": true, + }); + + assert!(hpay_channel_exit_deployment_verified( + true, + &deployment, + Some(code_hash), + Some(protocol::upgrade::MAINNET_CHAIN_ID), + Some(height + 1), + Some(height), + Some(code_hash), + )); + assert!(!hpay_channel_exit_deployment_verified( + true, + &deployment, + Some(code_hash), + Some(protocol::upgrade::MAINNET_CHAIN_ID), + Some(height + 1), + Some(height - 1), + Some(code_hash), + )); + assert!(!hpay_channel_exit_deployment_verified( + true, + &deployment, + Some(code_hash), + Some(protocol::upgrade::MAINNET_CHAIN_ID), + Some(height + 1), + Some(height), + Some(&"22".repeat(32)), + )); + assert!(!hpay_channel_exit_deployment_verified( + true, + &deployment, + Some(code_hash), + Some(7), + Some(height + 1), + Some(height), + Some(code_hash), + )); + } + + #[test] + fn verified_hvm_artifact_does_not_masquerade_as_native_channel_exit() { + let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); + let setup = test_setup(); + let now = 2_000_000_u64; + let block_one = "001e231cb03f9938d54f04407797b8188f0375eb10f0bcb426dccae87dcadb56"; + let evidence = json!({ + "schema": "hpay-hvm-channel-exit-evidence/1", + "manifest_valid": true, + "deployment_verified": true, + }); + let value = build_capabilities_with_tip_and_exit_evidence( + &config, + &setup, + MAINNET_MIN_SAFE_HEIGHT, + Some(block_one), + false, + now - 60, + now, + evidence, + ); + assert_eq!( + value["features"]["channel_unilateral_exit"].as_bool(), + Some(false) + ); + assert_eq!( + value["features"]["channel_unilateral_exit_evidence"]["deployment_verified"], + true + ); + } + + #[test] + fn fresh_synced_mainnet_reports_type2_channel_payment_readiness() { + let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); + let setup = test_setup(); + let now = 2_000_000_u64; + let block_one = "001e231cb03f9938d54f04407797b8188f0375eb10f0bcb426dccae87dcadb56"; + let value = build_capabilities_with_tip( + &config, + &setup, + MAINNET_MIN_SAFE_HEIGHT, + Some(block_one), + false, + now - 60, + now, + ); + assert_eq!(value["network"]["kind"], "mainnet"); + assert_eq!(value["network"]["node_profile_id"], "hacash-mainnet"); + assert_eq!(value["network"]["funding_confirmed"], false); + assert_eq!(value["network"]["transaction_ready"], true); + assert_eq!(value["sync"]["fresh"], true); + assert_eq!(value["features"]["type4_mainnet"], false); + } + #[test] fn registered_and_enabled_lists_are_sorted() { let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); let setup = test_setup(); - let value = build_capabilities(&config, &setup, protocol::upgrade::ONLINE_OPEN_HEIGHT); + let value = build_capabilities( + &config, + &setup, + protocol::upgrade::ONLINE_OPEN_HEIGHT, + None, + false, + ); for path in [ &value["transactions"]["registered"], @@ -221,7 +758,7 @@ mod node_capabilities_tests { let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); let setup = test_setup(); let height = protocol::upgrade::ONLINE_OPEN_HEIGHT - 2; - let value = build_capabilities(&config, &setup, height); + let value = build_capabilities(&config, &setup, height, None, false); assert_eq!(value["istanbul"]["active"].as_bool(), Some(false)); assert!(!numbers(&value["transactions"]["enabled"]).contains(&3)); @@ -233,7 +770,13 @@ mod node_capabilities_tests { let setup = test_setup(); assert!(!setup.has_vm_assigner()); - let value = build_capabilities(&config, &setup, protocol::upgrade::ONLINE_OPEN_HEIGHT); + let value = build_capabilities( + &config, + &setup, + protocol::upgrade::ONLINE_OPEN_HEIGHT, + None, + false, + ); let features = &value["features"]; for name in [ @@ -258,4 +801,129 @@ mod node_capabilities_tests { assert_eq!(features[name].as_bool(), Some(false), "feature {name}"); } } + + #[test] + fn sync_capability_rejects_stale_and_future_tips() { + let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); + let setup = test_setup(); + let now = 2_000_000_u64; + let fresh = build_capabilities_with_tip( + &config, + &setup, + protocol::upgrade::ONLINE_OPEN_HEIGHT, + None, + false, + now - 60, + now, + ); + assert_eq!(fresh["sync"]["fresh"], true); + assert_eq!(fresh["sync"]["tip_age_seconds"], 60); + + let stale = build_capabilities_with_tip( + &config, + &setup, + protocol::upgrade::ONLINE_OPEN_HEIGHT, + None, + false, + now - 3_601, + now, + ); + assert_eq!(stale["sync"]["fresh"], false); + + let future = build_capabilities_with_tip(&config, &setup, 1, None, false, now + 121, now); + assert_eq!(future["sync"]["fresh"], false); + } + + #[test] + fn local_pilot_requires_block_one_two_blocks_and_confirmed_funding() { + let mut config = test_config(7); + config.network_kind = LOCAL_PILOT_NETWORK_KIND.to_owned(); + config.node_profile_id = LOCAL_PILOT_PROFILE_ID.to_owned(); + let setup = test_setup(); + let block_one = "000008c8c945c4ca797f5aa70530caa51030ee0037e76410fd113852d50f2dff"; + + let empty = build_capabilities(&config, &setup, 0, None, false); + assert_eq!(empty["network"]["block_1_available"], false); + assert_eq!(empty["network"]["transaction_ready"], false); + + let one_block = build_capabilities(&config, &setup, 1, Some(block_one), true); + assert_eq!(one_block["network"]["transaction_ready"], false); + + let unfunded = build_capabilities(&config, &setup, 2, Some(block_one), false); + assert_eq!(unfunded["network"]["transaction_ready"], false); + + let ready = build_capabilities(&config, &setup, 2, Some(block_one), true); + assert_eq!(ready["network"]["kind"], LOCAL_PILOT_NETWORK_KIND); + assert_eq!(ready["network"]["block_1_hash"], block_one); + assert_eq!(ready["network"]["funding_confirmed"], true); + assert_eq!(ready["network"]["transaction_ready"], true); + assert_eq!(ready["network"]["instance_id"].as_str().unwrap().len(), 64); + } + + #[test] + fn hpay_security_routes_are_advertised_and_registered() { + let routes = NodeCapabilitiesService.routes(); + assert!(routes.iter().any(|route| { + route.method == basis::interface::ApiMethod::Post + && route.path == "/submit/transaction/hpay-bound" + })); + assert!(routes.iter().any(|route| { + route.method == basis::interface::ApiMethod::Get + && route.path == "/query/hpay/channel-registry" + })); + + let config = test_config(protocol::upgrade::MAINNET_CHAIN_ID); + let setup = test_setup(); + let value = build_capabilities(&config, &setup, 1, None, false); + assert_eq!(value["api"]["transaction_submit_bound"], true); + assert_eq!( + value["api"]["hpay_channel_registry_query"], + value["features"]["hvm"] + ); + } + + #[test] + fn bound_submit_parameters_are_canonical_and_fail_closed() { + let instance = "ab".repeat(32); + let request = |chain_id: Option<&str>, instance_id: Option<&str>| ApiRequest { + query: [ + chain_id.map(|value| ("chain_id".to_owned(), value.to_owned())), + instance_id.map(|value| ("network_instance_id".to_owned(), value.to_owned())), + ] + .into_iter() + .flatten() + .collect(), + ..ApiRequest::default() + }; + + let valid = request(Some("7"), Some(&instance)); + assert_eq!(required_bound_chain_id(&valid).unwrap(), 7); + assert_eq!( + required_bound_network_instance_id(&valid).unwrap(), + instance + ); + for invalid in [None, Some(""), Some("07"), Some("-1"), Some("4294967296")] { + assert!(required_bound_chain_id(&request(invalid, Some(&"ab".repeat(32)))).is_err()); + } + for invalid in [None, Some(""), Some("AB"), Some("zz"), Some("ab12")] { + assert!(required_bound_network_instance_id(&request(Some("7"), invalid)).is_err()); + } + } + + #[test] + fn bound_submit_requires_exact_chain_and_network_instance() { + let actual = CurrentNetworkInstance { + chain_id: 7, + instance_id: "ab".repeat(32), + }; + assert!(validate_bound_network_identity(7, &actual.instance_id, &actual).is_ok()); + assert_eq!( + validate_bound_network_identity(0, &actual.instance_id, &actual).unwrap_err(), + "HPAY bound submit chain_id mismatch" + ); + assert_eq!( + validate_bound_network_identity(7, &"cd".repeat(32), &actual).unwrap_err(), + "HPAY bound submit network_instance_id mismatch" + ); + } } diff --git a/app/src/nvidia_launch.rs b/app/src/nvidia_launch.rs new file mode 100644 index 00000000..c4162c03 --- /dev/null +++ b/app/src/nvidia_launch.rs @@ -0,0 +1,901 @@ +//! What an NVIDIA card can actually hold of the x16rs batch kernel, and what +//! that leaves worth sweeping. +//! +//! This module contains no CUDA and opens no device. It is the arithmetic that +//! turns four numbers the CUDA build reports about the kernel into the two ends +//! of a search space, so that the NVIDIA grid and the NVIDIA presets can be +//! derived and TESTED on a machine with no NVIDIA card in it. +//! +//! # The four numbers, and what they imply +//! +//! `cudaFuncGetAttributes` on the batch kernel, from the CUDA build on a Tesla +//! T4 (sm_75): +//! +//! numRegs = 255, staticShared = 33984 B, maxThreadsPerBlock = 256, +//! localPerThread = 792 B +//! +//! The block size is not a tuning axis: `block_miner.cu` declares +//! `__shared__ unsigned int local_nonces[256]` and reduces over a power-of-two +//! tree across the block, so 256 threads (8 warps) is the only size it is +//! correct at. Put those against one Turing multiprocessor's budgets: +//! +//! * **Registers.** sm_75 has 65536 registers per SM, allocated per warp in +//! units of 256. One warp of this kernel takes +//! `ceil(255 * 32 / 256) * 256 = 8192` registers, so a block of 8 warps +//! takes 65536: the ENTIRE register file, with nothing left over. Blocks per +//! SM by registers = 1. +//! * **Shared memory.** 64 KiB per SM against 33984 B a block. Two blocks +//! would need 66.4 KiB. Blocks per SM by shared memory = 1. +//! * **Threads.** 1024 per SM against 256 a block would allow 4. +//! * **Blocks.** sm_75 allows 16 resident blocks per SM. +//! +//! The binding limit is 1, and it is binding twice over. So one multiprocessor +//! holds exactly ONE block of this kernel: 8 warps of the 32 the SM can track, +//! which is 25% occupancy. That is not a T4 quirk. Every NVIDIA architecture +//! from Volta to Blackwell has 65536 registers per SM (see [`SM_BUDGETS`]), and +//! 255 registers on 256 threads consumes all of them on every one of them, so +//! [`residency`] returns one block per SM for every entry in that table. The +//! measured confirmation is in `x16rs-cuda`: the runtime's own +//! `cudaOccupancyMaxActiveBlocksPerMultiprocessor` reported +//! `blocks_per_multiprocessor = 1` on the real T4. +//! +//! Three consequences, and they are the whole shape of the NVIDIA search space: +//! +//! 1. **The work-group floor is the multiprocessor count.** Below it, SMs have +//! no work at all and the hashrate describes the launch being too small. +//! `CudaDeviceLimits::work_groups_that_fill_the_card` is exactly this. +//! 2. **Above that floor, work_groups is a WAVE COUNT and nothing else.** With +//! one resident block per SM there is no second block to overlap with; a +//! launch of `k * sm_count` blocks runs k waves back to back. It does not +//! add concurrency. All it adds is batch length, and the only thing longer +//! batches buy is amortising the launch: one wave at unit_size 64 on a T4 +//! is about 87 ms of work against a launch overhead of a few microseconds, +//! so that is already paid off at ONE wave. See [`wave_ceiling`] for what +//! bounds it from above, which is latency rather than throughput. +//! 3. **unit_size is the only axis that changes what a resident block does.** +//! At 25% occupancy each warp has to cover its own latency, and the card +//! may or may not have the power headroom to let it. Which way that goes is +//! not derivable, it is measurable, and the two cards measured go OPPOSITE +//! ways: an RX 9070 XT wants 192 (latency bound, underfed), a Tesla T4 +//! wants 64 or less (66 to 67 W against a 70 W cap, so a bigger batch buys +//! nothing the power limit will allow). Which is why the honest NVIDIA +//! unit_size default is the smallest value anyone has MEASURED to win, and +//! why the tuner's axis has to start below it. +//! +//! # What is derived here and what is not +//! +//! Derived: the residency, the work-group floor, the wave ceiling, and the fact +//! that the wave ceiling is card-size independent. Measured: the per-SM rate and +//! the unit_size ordering, both from one T4, both quoted with their source. +//! Chosen: where on the ladder between the floor and the ceiling each preset +//! tier sits. [`PRESET_LADDER`] says which is which, one line per number. + +/// One NVIDIA multiprocessor's budgets, per compute capability. +/// +/// From the CUDA C Programming Guide's "Technical Specifications per Compute +/// Capability" table. Only the fields this kernel's residency depends on are +/// carried; a field that never binds for a 256-thread block is still here +/// because leaving it out would hide WHY it never binds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SmBudget { + pub compute_major: u32, + pub compute_minor: u32, + /// 32-bit registers in one multiprocessor's register file. + pub registers_per_sm: u32, + /// Registers are allocated a warp at a time, rounded up to this many. + pub register_alloc_unit: u32, + /// Shared memory one multiprocessor can hand out to resident blocks. + pub shared_bytes_per_sm: u32, + pub max_threads_per_sm: u32, + pub max_warps_per_sm: u32, + pub max_blocks_per_sm: u32, +} + +/// Every NVIDIA architecture this miner can meet, oldest first. +/// +/// The column that decides everything is `registers_per_sm`, and the striking +/// thing about it is that it has not moved since Volta: 65536 on all of them. +/// A kernel at 255 registers a thread on a 256-thread block wants exactly 65536, +/// so ONE block per SM is not a property of the T4 that happened to be measured. +/// It is a property of this kernel on NVIDIA hardware, full stop. +pub const SM_BUDGETS: [SmBudget; 8] = [ + // Volta, Titan V / V100. + SmBudget { + compute_major: 7, + compute_minor: 0, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 98_304, + max_threads_per_sm: 2_048, + max_warps_per_sm: 64, + max_blocks_per_sm: 32, + }, + // Turing, T4 / RTX 20. The card the kernel attributes above came from. + SmBudget { + compute_major: 7, + compute_minor: 5, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 65_536, + max_threads_per_sm: 1_024, + max_warps_per_sm: 32, + max_blocks_per_sm: 16, + }, + // Ampere GA100, A100. + SmBudget { + compute_major: 8, + compute_minor: 0, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 167_936, + max_threads_per_sm: 2_048, + max_warps_per_sm: 64, + max_blocks_per_sm: 32, + }, + // Ampere GA10x, RTX 30. + SmBudget { + compute_major: 8, + compute_minor: 6, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 102_400, + max_threads_per_sm: 1_536, + max_warps_per_sm: 48, + max_blocks_per_sm: 16, + }, + // Ada Lovelace, RTX 40. + SmBudget { + compute_major: 8, + compute_minor: 9, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 102_400, + max_threads_per_sm: 1_536, + max_warps_per_sm: 48, + max_blocks_per_sm: 24, + }, + // Hopper, H100. + SmBudget { + compute_major: 9, + compute_minor: 0, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 233_472, + max_threads_per_sm: 2_048, + max_warps_per_sm: 64, + max_blocks_per_sm: 32, + }, + // Blackwell datacentre, B100/B200. + SmBudget { + compute_major: 10, + compute_minor: 0, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 233_472, + max_threads_per_sm: 2_048, + max_warps_per_sm: 64, + max_blocks_per_sm: 32, + }, + // Blackwell consumer, RTX 50. + SmBudget { + compute_major: 12, + compute_minor: 0, + registers_per_sm: 65_536, + register_alloc_unit: 256, + shared_bytes_per_sm: 102_400, + max_threads_per_sm: 1_536, + max_warps_per_sm: 48, + max_blocks_per_sm: 24, + }, +]; + +/// The budgets for a compute capability, or the closest older one this table +/// knows. +/// +/// A card newer than the table is treated as the newest entry rather than +/// refused: every entry agrees on the register file, which is the limit that +/// binds, so the answer for an unknown NVIDIA card is the same answer. A card +/// OLDER than the oldest entry falls back to the oldest, and the note in +/// [`Residency::limited_by`] says which entry answered. +pub fn sm_budget(compute_major: u32, compute_minor: u32) -> SmBudget { + let key = (compute_major, compute_minor); + let mut best = SM_BUDGETS[0]; + for budget in SM_BUDGETS { + if (budget.compute_major, budget.compute_minor) <= key { + best = budget; + } + } + best +} + +/// The kernel's own resource use, as `cudaFuncGetAttributes` reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KernelResidency { + pub regs_per_thread: u32, + pub static_shared_bytes: u32, + pub threads_per_block: u32, + pub warp_size: u32, +} + +/// `x16rs_cuda_main`, measured on the CUDA build. +/// +/// Not a guess and not a target: these are the numbers the CUDA build printed +/// for its own kernel on a real Tesla T4, and they are quoted in +/// `CudaDeviceLimits::describe` so an operator's log can be checked against +/// them. `localPerThread = 792 B` is spill, and it is not in this struct because +/// local memory is backed by device DRAM and does not bound residency; it +/// bounds bandwidth, which is a different argument. +pub const X16RS_BATCH_KERNEL: KernelResidency = KernelResidency { + regs_per_thread: 255, + static_shared_bytes: 33_984, + threads_per_block: 256, + warp_size: 32, +}; + +/// What one multiprocessor holds, and which budget said so. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Residency { + pub blocks_per_sm: u32, + pub warps_per_sm: u32, + /// Warps resident against warps the SM can track, in percent. This kernel + /// is register bound, so it is low by construction and cannot be raised + /// from the host side by any launch shape. + pub occupancy_percent_x10: u32, + /// Registers one block consumes, after per-warp rounding. + pub registers_per_block: u32, + /// Which of the four budgets produced `blocks_per_sm`. When several tie, + /// the tightest ones are named together, because "shared memory would have + /// allowed 3" and "shared memory also allows exactly 1" are different facts + /// about how much slack a kernel edit has. + pub limited_by: &'static str, +} + +/// Registers one warp of `kernel` consumes, after the hardware's per-warp +/// rounding. +pub fn registers_per_warp(kernel: KernelResidency) -> u32 { + let unit = kernel.register_alloc_unit_or_default(); + let raw = kernel.regs_per_thread.max(1) * kernel.warp_size.max(1); + raw.div_ceil(unit) * unit +} + +impl KernelResidency { + fn register_alloc_unit_or_default(self) -> u32 { + // Every entry in SM_BUDGETS agrees on 256, and a kernel struct has no + // architecture of its own; `residency` passes the real one in. + 256 + } + + pub fn warps_per_block(self) -> u32 { + self.threads_per_block + .max(1) + .div_ceil(self.warp_size.max(1)) + } +} + +/// How many blocks of `kernel` one `budget` multiprocessor holds at once. +/// +/// This is `cudaOccupancyMaxActiveBlocksPerMultiprocessor` done on the host with +/// no CUDA, so that the search space it implies can be tested without a card. +/// It is checked against the runtime's own answer on the one NVIDIA GPU this +/// project has run on, in +/// `the_host_side_occupancy_agrees_with_what_the_t4_runtime_reported`. +pub fn residency(kernel: KernelResidency, budget: SmBudget) -> Residency { + let warps = kernel.warps_per_block(); + let per_warp = kernel.regs_per_thread.max(1) * kernel.warp_size.max(1); + let unit = budget.register_alloc_unit.max(1); + let regs_per_block = per_warp.div_ceil(unit) * unit * warps; + + let by_registers = budget.registers_per_sm / regs_per_block.max(1); + let by_shared = if kernel.static_shared_bytes == 0 { + u32::MAX + } else { + budget.shared_bytes_per_sm / kernel.static_shared_bytes + }; + let by_threads = budget.max_threads_per_sm / kernel.threads_per_block.max(1); + let by_blocks = budget.max_blocks_per_sm; + + let blocks = by_registers.min(by_shared).min(by_threads).min(by_blocks); + let limited_by = match ( + by_registers == blocks, + by_shared == blocks, + by_threads == blocks, + ) { + (true, true, _) => "registers and shared memory, both exactly", + (true, false, _) => "registers", + (false, true, _) => "shared memory", + (false, false, true) => "threads per multiprocessor", + (false, false, false) => "resident blocks per multiprocessor", + }; + + let resident_warps = blocks * warps; + Residency { + blocks_per_sm: blocks, + warps_per_sm: resident_warps, + occupancy_percent_x10: (resident_warps * 1_000) / budget.max_warps_per_sm.max(1), + registers_per_block: regs_per_block, + limited_by, + } +} + +// --------------------------------------------------------------------------- +// From residency to a search space +// --------------------------------------------------------------------------- + +/// The only block size `block_miner.cu` is correct at. +pub const CUDA_LOCAL_SIZE: u32 = 256; + +/// The smallest launch that leaves no multiprocessor idle. +/// +/// This is the work-group axis's floor, and it is not a preference. A launch of +/// fewer blocks than the card has multiprocessors leaves some of them with +/// nothing to do, so its hashrate is a statement about the launch being too +/// small rather than about the shape. +pub fn work_group_floor(sm_count: u32, blocks_per_sm: u32) -> u32 { + sm_count.max(1).saturating_mul(blocks_per_sm.max(1)).max(1) +} + +/// How many times over a launch fills the card. +pub fn waves(work_groups: u32, sm_count: u32, blocks_per_sm: u32) -> f64 { + work_groups as f64 / work_group_floor(sm_count, blocks_per_sm) as f64 +} + +/// The p95 batch-latency ceiling the tuner scores against, in milliseconds. +/// +/// Kept here as its own constant because `autotune16` is compiled only with a +/// GPU backend or under `cfg(test)`, and this module has to answer without +/// either. `the_two_copies_of_the_latency_ceiling_are_one_number` asserts they +/// agree, so a change to one fails rather than drifts. +pub const P95_BATCH_CEILING_MS: f64 = 1_500.0; + +/// The most waves whose batch still comes in under the latency ceiling. +/// +/// A batch is atomic: a template change cannot take effect until the launch +/// returns, so a long batch throws away work and delays every job switch. The +/// tuner refuses any shape whose p95 batch exceeds `P95_BATCH_CEILING_MS`, which +/// makes this the real ceiling on the work-group axis. Memory is not: a T4 holds +/// about 7400 work groups at unit_size 128 and the latency ceiling allows about +/// 344 of them. +/// +/// # Why the answer is a wave count and not a work-group count +/// +/// A batch of `w` waves is `w * sm_count * local_size * unit_size` nonces, and a +/// card with `sm_count` multiprocessors hashes at about `sm_count * r` where `r` +/// is the per-SM rate. The `sm_count` cancels: +/// +/// `w <= ceiling_seconds * r / (local_size * unit_size)` +/// +/// So the ceiling is the SAME number of waves on a 40-SM T4 and a 170-SM 5090, +/// as long as the per-SM rate is comparable, which for a kernel pinned at one +/// block and 8 warps per SM it broadly is. That is what makes a card-agnostic +/// preset table possible at all on this vendor: the tier can be a wave count. +pub fn wave_ceiling(unit_size: u32, local_size: u32, hashes_per_second_per_sm: f64) -> f64 { + let per_wave = (local_size.max(1) as f64) * (unit_size.max(1) as f64); + if per_wave <= 0.0 || !hashes_per_second_per_sm.is_finite() { + return 0.0; + } + (P95_BATCH_CEILING_MS / 1000.0) * hashes_per_second_per_sm / per_wave +} + +// --------------------------------------------------------------------------- +// The one NVIDIA measurement there is +// --------------------------------------------------------------------------- + +/// Multiprocessors on the Tesla T4 the kernel was measured on. +pub const MEASURED_T4_SM_COUNT: u32 = 40; + +/// The T4's steady-state rate at its best measured shape. +/// +/// Colab Tesla T4, repeat 16, fixed corpus, steady state after 40 warm-up +/// batches, flat to 0.57%, at work_groups 256, local_size 256, unit_size 64. +/// The other two points measured in the same run were 7.19 MH/s at unit_size 96 +/// and 7.06 at 128. `nvidia-smi` during the run: 66 to 67 W against a 70 W cap, +/// 63 to 74 C, SM clock 1140 to 1305 MHz. The card was POWER capped. +pub const MEASURED_T4_HPS: f64 = 7.54e6; + +/// The best unit_size anyone has measured on an NVIDIA card. +/// +/// It is the SMALLEST of the three that were measured and the trend was still +/// falling, so the real optimum may well be below it. That is the reason the +/// tuner's unit_size axis starts at 32 and the reason no preset here names a +/// value above this one. +pub const MEASURED_T4_BEST_UNIT_SIZE: u32 = 64; + +/// The T4's rate per multiprocessor, which is the quantity [`wave_ceiling`] +/// needs and the only one that transfers between cards of different sizes. +/// +/// 7.54 MH/s over 40 multiprocessors is 188.5 kH/s each. On a card whose clocks +/// are not being held down by a 70 W cap this is pessimistic, and pessimistic is +/// the safe direction: it makes the wave ceiling SMALLER, so a preset derived +/// from it has shorter batches than it strictly needs rather than longer. +pub fn measured_hashes_per_second_per_sm() -> f64 { + MEASURED_T4_HPS / MEASURED_T4_SM_COUNT as f64 +} + +// --------------------------------------------------------------------------- +// The presets +// --------------------------------------------------------------------------- + +/// One preset tier: what the miner runs on an NVIDIA card BEFORE anybody tunes +/// it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NvidiaPreset { + pub tier: i8, + pub profile: &'static str, + pub work_groups: u32, + pub unit_size: u32, +} + +/// The NVIDIA preset ladder, and exactly how much of it is measurement. +/// +/// # THESE ARE NOT MEASURED SHAPES. Read this before quoting one. +/// +/// Nobody has run a hashrate sweep on any of these cards. What follows is the +/// most defensible starting point the hardware and the one T4 measurement +/// allow, and it exists to be REPLACED by `x16rs_gate`/autotune on the operator's +/// own card. The panel's Auto Tune button is what turns these into numbers. +/// +/// **unit_size 64 on every tier, and the first reason given for it was wrong.** +/// +/// This used to cite a hand sweep: 64 -> 7.54 MH/s, 96 -> 7.19, 128 -> 7.06, and +/// conclude that smaller wins. That sweep ran the three shapes back to back in +/// one loop on a passively cooled card that climbed from 63 to 74 C as it went, +/// so it may have measured nothing but the order they ran in. The tuner, which +/// re-runs finalists in alternating order for exactly this reason, disagrees: at +/// 256 work groups it measured 32 -> 6.62, 64 -> 6.91, 128 -> 7.29, the opposite +/// ordering. +/// +/// 64 survives, but as the partner of a high work-group count rather than as a +/// small value that wins on its own. At a MATCHED batch of 8.39M nonces: +/// +/// ```text +/// 512 x 256 x 64 7.48 MH/s +/// 256 x 256 x 128 7.29 MH/s +/// ``` +/// +/// Same nonces, arranged two ways, and the wider one wins. That is the axis this +/// vendor cares about: with one resident block per SM (see [`residency`]), +/// work_groups is blocks spread across the multiprocessors, while unit_size is +/// serial work inside a thread. NVIDIA wants width. The RX 9070 XT wanted depth, +/// where unit_size beat work_groups by about 11% at a matched batch, which is +/// why no single table serves both. +/// +/// The tuner's axis still runs 32/48/64/96/128 so a card that wants otherwise +/// can find it. +/// +/// **work_groups: a wave count, bounded by the latency ceiling.** +/// Derived: one resident block per SM makes work_groups a wave count, and +/// [`wave_ceiling`] at unit_size 64 on the measured per-SM rate is 17.2 waves. +/// Chosen: where each tier sits between 1 wave and that ceiling. The numbers +/// below are multiples of 64 because `gpu_arch::tune_workgroups` rounds to 64 +/// and clamps to at least 256, and they are checked in +/// `every_nvidia_preset_lands_inside_the_derived_bracket` against the real +/// multiprocessor counts of every NVIDIA card in `PANEL_GPU_PRESETS`. +/// +/// On the one NVIDIA card measured, a higher tier IS faster, and this used to +/// say the opposite. A full tune on a Colab T4 at repeat 16, every candidate +/// proved against the CPU oracle over its whole window before its number +/// counted: +/// +/// ```text +/// 256 x 256 x 64 6.91 MH/s <- what nvidia_balanced resolves near +/// 384 x 256 x 64 7.29 +/// 512 x 256 x 64 7.48 66 W 111.7 kH/J <- tier 3, the winner +/// ``` +/// +/// About +8% from the middle of the ladder to tier 3, well outside the 2.6% +/// between-process spread. The card drew 66 W against its 70 W cap throughout, +/// so this is not more power buying more hashes; it is the same power spread +/// across more of the chip. +/// +/// Three things that run WITH that number and must travel with it: +/// +/// The tune could not separate the top two. 512x256x64 measured 7.468 and +/// 192x256x128 measured 7.457, 0.14% apart, while one shape's own three repeats +/// spanned 2.35%. The tuner said so rather than crowning one, and the tier 3 +/// entry is the larger median of a coin toss. +/// +/// The soak did not settle. 36 passes over 123 s and the hashrate was still +/// drifting 0.35%, so the winner is NOT proven to sustain and the tuner refused +/// to write it into a config. The drift is twenty times smaller than the gain, +/// which is why the ladder keeps the shape; it is not a reason to call it +/// settled. +/// +/// One card, one session. A T4 is 40 SMs at a 70 W cap and a datacenter blower; +/// a desktop 4090 is none of those things. This is the best evidence anyone has +/// and it is still one card. +/// +/// So the ladder is an aggressiveness dial whose top end stays inside the +/// latency ceiling. On the T4 the dial also happens to order by speed. That is a +/// measurement on one card, not a promise about yours, and it is what the tuner +/// exists to settle. +pub const PRESET_LADDER: [NvidiaPreset; 5] = [ + NvidiaPreset { + tier: 0, + profile: "nvidia_eco", + work_groups: 256, + unit_size: 64, + }, + NvidiaPreset { + tier: 1, + profile: "nvidia_balanced", + work_groups: 320, + unit_size: 64, + }, + NvidiaPreset { + tier: 2, + profile: "nvidia_profit", + work_groups: 384, + unit_size: 64, + }, + NvidiaPreset { + tier: 3, + profile: "nvidia_performance", + work_groups: 512, + unit_size: 64, + }, + NvidiaPreset { + tier: 4, + profile: "nvidia_max", + work_groups: 768, + unit_size: 64, + }, +]; + +/// The shape a named NVIDIA profile starts at, or `None` if it is not one. +pub fn preset_tuning(profile: &str) -> Option<(u32, u32)> { + PRESET_LADDER + .iter() + .find(|preset| preset.profile == profile) + .map(|preset| (preset.work_groups, preset.unit_size)) +} + +/// Multiprocessor counts of the NVIDIA cards the panel offers, so the preset +/// ladder can be checked in WAVES rather than in work groups. +/// +/// Published die configurations, one entry per panel slug in +/// `gpu_arch::PANEL_GPU_PRESETS`. They are here rather than in the panel because +/// nothing the panel does needs them: this is the denominator that turns a +/// work-group count into the only unit that means anything on this vendor. +pub const NVIDIA_PANEL_SM_COUNTS: [(&str, u32); 9] = [ + ("rtx3060", 28), + ("rtx4060", 24), + ("rtx3070", 46), + ("rtx4070", 46), + ("rtx4090", 128), + ("rtx5060", 30), + ("rtx5070", 48), + ("rtx5080", 84), + ("rtx5090", 170), +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// The four budgets, worked through by hand for the one card that was + /// measured, against the answer the CUDA runtime gave on it. + /// + /// `x16rs-cuda`'s own T4 fixture records + /// `blocks_per_multiprocessor: 1` straight from + /// `cudaOccupancyMaxActiveBlocksPerMultiprocessor`. This module derives that + /// number from the published SM budgets instead. If the two ever disagree, + /// one of them is wrong and the search space is built on it. + #[test] + fn the_host_side_occupancy_agrees_with_what_the_t4_runtime_reported() { + let turing = sm_budget(7, 5); + let r = residency(X16RS_BATCH_KERNEL, turing); + + // 255 * 32 = 8160, rounded up to a multiple of 256 is 8192, times 8 + // warps is 65536: the entire register file of a Turing SM. + assert_eq!(registers_per_warp(X16RS_BATCH_KERNEL), 8_192); + assert_eq!(r.registers_per_block, 65_536); + assert_eq!(r.registers_per_block, turing.registers_per_sm); + + // And shared memory says one as well, independently: 2 * 33984 is + // 67968, which is over the 65536 a Turing SM hands out. + assert!(2 * X16RS_BATCH_KERNEL.static_shared_bytes > turing.shared_bytes_per_sm); + + assert_eq!(r.blocks_per_sm, 1, "the CUDA runtime reported 1 on the T4"); + assert_eq!(r.limited_by, "registers and shared memory, both exactly"); + assert_eq!(r.warps_per_sm, 8); + // 8 of 32 warps: 25.0%. + assert_eq!(r.occupancy_percent_x10, 250); + } + + /// One block per SM on every NVIDIA architecture, not just the one measured. + /// + /// This is the load-bearing generalisation. If it failed on some + /// architecture, that card's work-group floor would be a multiple of its SM + /// count and every wave figure in this module would be wrong for it. + #[test] + fn every_nvidia_architecture_holds_exactly_one_block_of_this_kernel() { + for budget in SM_BUDGETS { + let r = residency(X16RS_BATCH_KERNEL, budget); + assert_eq!( + r.blocks_per_sm, 1, + "sm_{}{} holds {} blocks, so the wave arithmetic does not apply to it", + budget.compute_major, budget.compute_minor, r.blocks_per_sm + ); + // Register bound on all of them, because the register file has been + // 65536 since Volta and this kernel wants all of it. + assert_eq!(budget.registers_per_sm, 65_536); + assert!( + r.limited_by.contains("registers"), + "sm_{}{} is limited by {} instead", + budget.compute_major, + budget.compute_minor, + r.limited_by + ); + // Occupancy never exceeds a third, and cannot be raised by any + // launch shape: it is decided before the host picks one. + assert!( + r.occupancy_percent_x10 <= 250, + "sm_{}{} at {:.1}% occupancy", + budget.compute_major, + budget.compute_minor, + r.occupancy_percent_x10 as f64 / 10.0 + ); + } + } + + /// A kernel that used fewer registers really would fit more blocks, so the + /// test above is measuring the kernel and not a constant `1`. + #[test] + fn the_residency_arithmetic_responds_to_the_kernel_it_is_given() { + let turing = sm_budget(7, 5); + let lean = KernelResidency { + regs_per_thread: 32, + static_shared_bytes: 1_024, + threads_per_block: 256, + warp_size: 32, + }; + let r = residency(lean, turing); + // 32 * 32 = 1024 a warp, 8192 a block, so registers allow 8; threads + // allow 4; blocks allow 16. Threads bind. + assert_eq!(r.blocks_per_sm, 4); + assert_eq!(r.limited_by, "threads per multiprocessor"); + assert_eq!(r.warps_per_sm, 32); + assert_eq!(r.occupancy_percent_x10, 1_000); + + // And halving the register count of the real kernel is exactly what it + // would take to hold two blocks, which is the slack a kernel edit has: + // none. + let halved = KernelResidency { + regs_per_thread: 127, + static_shared_bytes: 32_768, + ..X16RS_BATCH_KERNEL + }; + assert_eq!(residency(halved, turing).blocks_per_sm, 2); + } + + /// The wave ceiling is the same number of waves whatever size the card is. + /// + /// This is the property that lets one preset table serve a 24-SM RTX 4060 + /// and a 170-SM RTX 5090 without naming either. + #[test] + fn the_latency_wave_ceiling_does_not_depend_on_how_big_the_card_is() { + let per_sm = measured_hashes_per_second_per_sm(); + let ceiling = wave_ceiling(MEASURED_T4_BEST_UNIT_SIZE, CUDA_LOCAL_SIZE, per_sm); + assert!( + (ceiling - 17.2).abs() < 0.1, + "17.2 waves at unit_size 64 on the measured per-SM rate, got {ceiling:.2}" + ); + + // Derived the long way round on three card sizes: turn the wave ceiling + // into work groups, into nonces, into seconds, and check the batch + // really does land on the latency ceiling. + for sm_count in [24u32, 40, 128, 170] { + let work_groups = (ceiling * sm_count as f64) as u32; + let nonces = + work_groups as u64 * CUDA_LOCAL_SIZE as u64 * MEASURED_T4_BEST_UNIT_SIZE as u64; + let card_hps = per_sm * sm_count as f64; + let batch_ms = nonces as f64 / card_hps * 1000.0; + assert!( + (batch_ms - P95_BATCH_CEILING_MS).abs() < 20.0, + "{sm_count} SMs: {work_groups} work groups is a {batch_ms:.0} ms batch" + ); + } + + // A bigger unit_size buys fewer waves, in exact proportion: the batch is + // the product, so doubling one halves the other. + let at_128 = wave_ceiling(128, CUDA_LOCAL_SIZE, per_sm); + assert!((at_128 * 2.0 - ceiling).abs() < 1e-6); + assert!(at_128 > 8.0 && at_128 < 9.0, "{at_128:.2} waves at 128"); + } + + /// One wave already amortises the launch, so waves beyond a handful buy + /// throughput that rounds to nothing and cost latency that does not. + /// + /// This is the argument for the preset ladder being short rather than the + /// 512..3584 it replaces. + #[test] + fn one_wave_is_already_long_enough_to_hide_a_kernel_launch() { + let per_sm = measured_hashes_per_second_per_sm(); + // One wave on a T4 at the measured optimum. + let nonces = MEASURED_T4_SM_COUNT as u64 + * CUDA_LOCAL_SIZE as u64 + * MEASURED_T4_BEST_UNIT_SIZE as u64; + let seconds = nonces as f64 / (per_sm * MEASURED_T4_SM_COUNT as f64); + assert!( + (seconds - 0.0869).abs() < 0.001, + "one T4 wave is {seconds:.4}s" + ); + // A CUDA kernel launch is single-digit microseconds. Against 87 ms that + // is under a tenth of a percent, so the whole throughput case for a long + // batch is already spent at one wave. + let launch_overhead_seconds = 10e-6; + assert!(launch_overhead_seconds / seconds < 0.001); + } + + /// Every preset lands between one wave and the latency ceiling, on every + /// NVIDIA card the panel offers. + /// + /// Table driven on purpose: this is the test that a future edit to the + /// ladder cannot get past without either staying inside the bracket or + /// changing the bracket and saying why. + #[test] + fn every_nvidia_preset_lands_inside_the_derived_bracket() { + let per_sm = measured_hashes_per_second_per_sm(); + let mut rows = Vec::new(); + + for preset in PRESET_LADDER { + // The whole ladder is at the one measured unit_size, and no tier is + // allowed above it: an unmeasured guess that is SLOWER than the one + // measurement is the defect this replaces. + assert!( + preset.unit_size <= MEASURED_T4_BEST_UNIT_SIZE, + "{} names unit_size {}, above the only measured NVIDIA optimum ({})", + preset.profile, + preset.unit_size, + MEASURED_T4_BEST_UNIT_SIZE + ); + // And on the tuner's grid, or the tune could never return to it. + assert!( + [32u32, 48, 64, 96, 128].contains(&preset.unit_size), + "{} names unit_size {}, which is not a grid point", + preset.profile, + preset.unit_size + ); + // `gpu_arch::tune_workgroups` rounds to a multiple of 64 and clamps + // to at least 256, so a preset outside that is not the shape the + // card is given. + assert!( + preset.work_groups >= 256 && preset.work_groups % 64 == 0, + "{} names {} work groups, which tune_workgroups would rewrite", + preset.profile, + preset.work_groups + ); + + let ceiling = wave_ceiling(preset.unit_size, CUDA_LOCAL_SIZE, per_sm); + for (slug, sm_count) in NVIDIA_PANEL_SM_COUNTS { + // A card only ever sees the tiers its own limits allow. + if preset.tier > crate::gpu_arch::ArchLimits::panel_max_tier(slug) { + continue; + } + let w = waves(preset.work_groups, sm_count, 1); + assert!( + w >= 1.0, + "{slug} ({sm_count} SMs) on {}: {w:.1} waves leaves multiprocessors idle", + preset.profile + ); + assert!( + w <= ceiling, + "{slug} ({sm_count} SMs) on {}: {w:.1} waves is over the {ceiling:.1} the \ + latency ceiling allows, so its batches exceed {P95_BATCH_CEILING_MS} ms", + preset.profile + ); + rows.push((slug, preset.profile, sm_count, preset.work_groups, w)); + } + } + + assert!( + rows.len() >= 30, + "only {} card/tier combinations were checked", + rows.len() + ); + } + + /// The table this replaces would not have passed the test above. + /// + /// Kept as a literal so the improvement is computed rather than asserted, + /// and so nobody reintroduces it believing it was fine. + #[test] + fn the_shipped_nvidia_table_was_outside_the_bracket_and_below_the_measurement() { + let shipped: [(&str, u32, u32); 5] = [ + ("nvidia_eco", 512, 128), + ("nvidia_balanced", 1024, 128), + ("nvidia_profit", 1280, 96), + ("nvidia_performance", 1792, 96), + ("nvidia_max", 3584, 128), + ]; + let per_sm = measured_hashes_per_second_per_sm(); + + for (profile, _, unit_size) in shipped { + assert!( + unit_size > MEASURED_T4_BEST_UNIT_SIZE, + "{profile} was supposed to be above the measured optimum" + ); + } + + // On the T4 itself, every shipped tier was over the latency ceiling. + let mut over = 0; + for (profile, work_groups, unit_size) in shipped { + let ceiling = wave_ceiling(unit_size, CUDA_LOCAL_SIZE, per_sm); + let w = waves(work_groups, MEASURED_T4_SM_COUNT, 1); + if w > ceiling { + over += 1; + } + let nonces = work_groups as u64 * CUDA_LOCAL_SIZE as u64 * unit_size as u64; + let batch_s = nonces as f64 / MEASURED_T4_HPS; + assert!( + batch_s > P95_BATCH_CEILING_MS / 1000.0, + "{profile} was a {batch_s:.2}s batch on a T4, which the tuner would have accepted" + ); + } + assert_eq!(over, 5, "every shipped tier was over the wave ceiling"); + + // The worst of them, spelled out: nvidia_max is 3584 x 256 x 128 = + // 117 M nonces, 15.6 s a batch on a T4, ten times the ceiling. + let max_batch = 3584u64 * 256 * 128; + assert!((max_batch as f64 / MEASURED_T4_HPS - 15.58).abs() < 0.1); + } + + /// `sm_budget` answers for cards this table has never heard of, and the + /// answer is the same one, because the register file is the same. + #[test] + fn an_unknown_compute_capability_still_gets_one_block_per_sm() { + for (major, minor) in [(6u32, 1u32), (7, 2), (8, 7), (9, 1), (13, 0), (99, 9)] { + let r = residency(X16RS_BATCH_KERNEL, sm_budget(major, minor)); + assert_eq!( + r.blocks_per_sm, 1, + "sm_{major}{minor} fell through to a budget that says {}", + r.blocks_per_sm + ); + } + // Exact matches come back exactly. + assert_eq!(sm_budget(7, 5).max_warps_per_sm, 32); + assert_eq!(sm_budget(8, 9).max_blocks_per_sm, 24); + // A capability below the whole table gets the oldest entry rather than + // a panic or a zero. + assert_eq!(sm_budget(3, 5).compute_major, 7); + } + + /// The tuner's ceiling and this module's copy of it are one number. + #[test] + fn the_two_copies_of_the_latency_ceiling_are_one_number() { + assert_eq!( + P95_BATCH_CEILING_MS, + crate::autotune16::P95_BATCH_CEILING_MS + ); + assert_eq!(CUDA_LOCAL_SIZE, 256); + } + + /// The profile names here and the ones `efficiency` dispatches on are the + /// same five, so a rename cannot leave a tier silently unmatched. + #[test] + fn the_ladder_covers_exactly_the_named_nvidia_profiles() { + for tier in 0i8..=4 { + let profile = crate::efficiency::tier_profile_for_vendor( + crate::gpu_arch::GpuVendor::Nvidia, + tier, + ); + let preset = PRESET_LADDER + .iter() + .find(|p| p.profile == profile) + .unwrap_or_else(|| panic!("{profile} has no entry in the ladder")); + assert_eq!(preset.tier, tier); + assert_eq!( + crate::efficiency::profile_tier(profile), + tier, + "{profile} disagrees about its own tier" + ); + assert_eq!( + crate::efficiency::profile_tuning(profile), + (preset.work_groups, preset.unit_size), + "{profile} in efficiency.rs is not the ladder entry" + ); + } + assert_eq!(preset_tuning("amd_max"), None); + assert_eq!(preset_tuning("nvidia_max"), Some((768, 64))); + } +} diff --git a/app/src/opencl_diag.rs b/app/src/opencl_diag.rs index a00c67d4..75a896be 100644 --- a/app/src/opencl_diag.rs +++ b/app/src/opencl_diag.rs @@ -401,7 +401,11 @@ pub fn print_scan_report(scan: &OpenClScan) { for plat in &scan.platforms { wlogln!( "Platform {}: {} vendor={} version={} AMD-APP build={}", - plat.index, plat.name, plat.vendor, plat.version, plat.amd_app_build + plat.index, + plat.name, + plat.vendor, + plat.version, + plat.amd_app_build ); for dev in &plat.devices { let kind = if dev.is_discrete { @@ -427,7 +431,11 @@ pub fn print_scan_report(scan: &OpenClScan) { if let Some(rec) = &scan.recommended { wlogln!( "Recommended: platform_id={} device_id={} {} ({}) AMD-APP {}", - rec.platform_id, rec.device_id, rec.device_name, rec.device_slug, rec.amd_app_build + rec.platform_id, + rec.device_id, + rec.device_name, + rec.device_slug, + rec.amd_app_build ); } if !scan.warnings.is_empty() { diff --git a/app/src/opencl_gpu/handle.rs b/app/src/opencl_gpu/handle.rs index 158789f9..adb0123f 100644 --- a/app/src/opencl_gpu/handle.rs +++ b/app/src/opencl_gpu/handle.rs @@ -277,7 +277,8 @@ impl OpenclGpuHandle { self.consecutive_errors.store(0, Relaxed); wlogerr!( "[OpenCL] Rebuilt GPU context (errors={}, work_groups={})", - n, rebuild_wg + n, + rebuild_wg ); } Err(e) => wlogerr!("[OpenCL] Context rebuild failed: {}", e), @@ -348,7 +349,8 @@ impl OpenclGpuHandle { } wlogerr!( "[OpenCL] work_groups ramp-up rebuild failed, staying at {}: {}", - capped, e + capped, + e ); } } diff --git a/app/src/opencl_gpu/init.rs b/app/src/opencl_gpu/init.rs index 95823fee..d148ffd6 100644 --- a/app/src/opencl_gpu/init.rs +++ b/app/src/opencl_gpu/init.rs @@ -221,7 +221,9 @@ pub fn initialize_opencl( if !quiet && compute_units > 0 { wlogln!( "[OpenCL] CU={} tuned work_groups={} (config {})", - compute_units, wg, workgroups + compute_units, + wg, + workgroups ); } if vram_bytes > 0 { @@ -270,7 +272,10 @@ pub fn initialize_opencl( if capped < wg && !quiet { wlogln!( "[OpenCL] {}: work_groups {} -> {} ({} AMD platform(s))", - slug, wg, capped, amd_plat_count + slug, + wg, + capped, + amd_plat_count ); wg = capped; } else if capped < wg { @@ -396,13 +401,15 @@ pub fn initialize_opencl( if e.contains("integrity self-test") { wlogerr!( "[efficiency] Skipping device {}: GPU hash integrity self-test failed (not a VRAM issue): {}", - device_id, e + device_id, + e ); continue; } wlogerr!( "[efficiency] OpenCL buffer init failed at work_groups={}: {}", - wg, e + wg, + e ); let mut built = false; let wg_floor = arch_limits.init_buffer_floor_wg; diff --git a/app/src/panel_tuning.rs b/app/src/panel_tuning.rs index 83f71749..83549276 100644 --- a/app/src/panel_tuning.rs +++ b/app/src/panel_tuning.rs @@ -38,6 +38,47 @@ pub fn resolve_panel_tuning( let limits = ArchLimits::for_panel_slug(panel_slug); let vendor = profile_vendor(base_profile); + + // gfx1201 does not get its launch shape from the generic tier scaling, + // because that scaling lands on the two worst values this card has. + // + // 32 CUs host 2 work groups each, so a count that is an odd multiple of 32 + // leaves a half empty scheduling tail. A measured sweep dips at exactly 48 + // and 96 and nowhere else, and those were the two the scaling produced: + // Profit resolved to 48 and, once the unit_size ceiling was raised to the + // measured optimum, Max resolved to 96. + // + // Measured on an RX 9070 XT at repeat 16, fixed corpus, 0.5% noise floor, + // each shape proven byte identical to the shipped one against the CPU + // oracle before its number was believed: + // + // 48 x 256 x 48 (shipped) 19.13 MH/s + // 64 x 256 x 192 28.80 MH/s +50.4% + // + // The kernel is latency bound here, not busy, so what helps is nonces in + // flight; at a matched batch, unit_size buys that about 11% more cheaply + // than work_groups. Hence one work-group count for all three tiers and a + // unit_size that carries the tier. + // + // Power at the top shape is NOT yet measured. Eco and Profit stay well below + // it deliberately. + if limits.is_experimental() { + let max_tier = ArchLimits::panel_max_tier(panel_slug); + let base_tier = profile_tier(base_profile); + let min_tier = min_profile_tier_for_mode(mode); + let target_tier = (base_tier + mode_tier_offset(mode)).clamp(min_tier, max_tier); + let unit_size = match mode { + EfficiencyMode::Eco => 64, + EfficiencyMode::Profit => 128, + EfficiencyMode::Max => 192, + }; + return ResolvedPanelTuning { + profile: tier_profile_for_vendor(vendor, target_tier), + work_groups: 64, + unit_size: unit_size.min(limits.max_unit_size()), + }; + } + let base_tier = profile_tier(base_profile); let max_tier = ArchLimits::panel_max_tier(panel_slug); let min_tier = min_profile_tier_for_mode(mode); @@ -60,20 +101,42 @@ mod tests { use super::*; #[test] - fn rx9070xt_capped_conservatively() { + fn rx9070xt_tops_out_at_the_measured_optimum() { let t = resolve_panel_tuning("rx9070xt", "amd_performance", 16, EfficiencyMode::Max); - assert_eq!(t.work_groups, 64); - assert_eq!(t.unit_size, 64); + assert_eq!((t.work_groups, t.unit_size), (64, 192)); } + /// The three tiers must differ, and none of them may land on a shape this + /// card is bad at. + /// + /// 32 CUs host 2 work groups each, so an odd multiple of 32 leaves a half + /// empty scheduling tail. A measured sweep dips at exactly 48 and 96. Before + /// this table existed the generic scaling produced both: Profit sat on 48 + /// for every shipped build, and raising the unit_size ceiling to the + /// measured optimum moved Max onto 96. #[test] - fn rx9070xt_modes_use_distinct_safe_launch_sizes() { + fn rx9070xt_modes_differ_and_avoid_the_pathological_shapes() { let eco = resolve_panel_tuning("rx9070xt", "amd_balanced", 16, EfficiencyMode::Eco); let profit = resolve_panel_tuning("rx9070xt", "amd_balanced", 16, EfficiencyMode::Profit); let max = resolve_panel_tuning("rx9070xt", "amd_balanced", 16, EfficiencyMode::Max); - assert_eq!((eco.work_groups, eco.unit_size), (32, 32)); - assert_eq!((profit.work_groups, profit.unit_size), (48, 48)); - assert_eq!((max.work_groups, max.unit_size), (64, 64)); + + assert_eq!((eco.work_groups, eco.unit_size), (64, 64)); + assert_eq!((profit.work_groups, profit.unit_size), (64, 128)); + assert_eq!((max.work_groups, max.unit_size), (64, 192)); + + // Distinct, and ordered by how hard they drive the card. + assert!(eco.unit_size < profit.unit_size); + assert!(profit.unit_size < max.unit_size); + + for t in [&eco, &profit, &max] { + assert_ne!(t.work_groups, 48, "48 work groups is a measured dip"); + assert_ne!(t.work_groups, 96, "96 work groups is a measured dip"); + assert_eq!( + t.work_groups % 64, + 0, + "work groups must divide evenly across 32 CUs at 2 groups each" + ); + } } #[test] @@ -81,4 +144,80 @@ mod tests { let t = resolve_panel_tuning("rx7900xtx", "amd_max", 24, EfficiencyMode::Max); assert!(t.work_groups >= 1024); } + + /// The explicit tier table added for the RX 9070 XT must not have moved any + /// other card off the generic scaling. + /// + /// The check recomputes the generic path here rather than calling the branch + /// under test, so a change that quietly widened the `is_experimental()` + /// predicate would be caught by the numbers no longer matching, not merely + /// by the predicate agreeing with itself. + #[test] + fn the_explicit_tier_table_is_rx9070xt_only() { + use crate::gpu_arch::PANEL_GPU_PRESETS; + + for (slug, base_profile, vram_gb) in PANEL_GPU_PRESETS { + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + let got = resolve_panel_tuning(slug, base_profile, vram_gb, mode); + if slug == "rx9070xt" { + // One work-group count for all three tiers, unit_size carries + // the tier, and the top is the measured optimum. + assert_eq!(got.work_groups, 64, "{slug} {mode:?}"); + let expected_us = match mode { + EfficiencyMode::Eco => 64, + EfficiencyMode::Profit => 128, + EfficiencyMode::Max => 192, + }; + assert_eq!(got.unit_size, expected_us, "{slug} {mode:?}"); + continue; + } + + // The generic path, recomputed independently. + let vendor = profile_vendor(base_profile); + let max_tier = ArchLimits::panel_max_tier(slug); + let target_tier = (profile_tier(base_profile) + mode_tier_offset(mode)) + .clamp(min_profile_tier_for_mode(mode), max_tier); + let profile = tier_profile_for_vendor(vendor, target_tier); + let (wg, us) = bounded_profile_tuning( + profile, + ArchLimits::for_panel_slug(slug).panel_min_wg, + ArchLimits::panel_max_work_groups(slug, vram_gb), + ArchLimits::panel_max_unit_size(slug), + max_tier, + ); + assert_eq!(got.profile, profile, "{slug} {mode:?} profile"); + assert_eq!( + (got.work_groups, got.unit_size), + (wg, us), + "{slug} {mode:?} left the generic tier scaling" + ); + // And nothing outside gfx1201 may reach the raised ceiling, + // neither in what it resolves to nor in what it is allowed. + assert_eq!( + ArchLimits::panel_max_unit_size(slug), + 128, + "{slug} was given the gfx1201 unit_size ceiling" + ); + assert!(got.unit_size <= 128, "{slug} {mode:?} us {}", got.unit_size); + assert!(got.work_groups >= 256, "{slug} {mode:?}"); + } + } + } + + /// The no-GPU entry still resolves to nothing at all, on every mode. + #[test] + fn the_none_slug_writes_no_tuning() { + for mode in [ + EfficiencyMode::Eco, + EfficiencyMode::Profit, + EfficiencyMode::Max, + ] { + let t = resolve_panel_tuning("none", "", 0, mode); + assert_eq!((t.profile, t.work_groups, t.unit_size), ("", 0, 0)); + } + } } diff --git a/app/src/poworker.rs b/app/src/poworker.rs index 2cb2e5e8..0329d992 100644 --- a/app/src/poworker.rs +++ b/app/src/poworker.rs @@ -8,16 +8,8 @@ use serde_json::Value as JV; use crate::efficiency::*; -#[cfg(feature = "ocl")] -use basis::difficulty::*; -#[cfg(any(feature = "ocl", test))] -use field::*; -#[cfg(any(feature = "ocl", test))] -use protocol::block::*; use sys::*; -#[cfg(feature = "ocl")] -use crate::opencl_gpu::block::do_group_block_mining_opencl; #[cfg(feature = "ocl")] use crate::opencl_gpu::initialize_opencl; #[cfg(feature = "cuda")] @@ -96,7 +88,11 @@ impl PoWorkConf { let active = efficiency.initial_active_supervene(configured_supervene); let runtime = MiningRuntimeState::new(tuning.workgroups, active); let cnf = PoWorkConf { - rpcaddr: ini_must(sec, "connect", "127.0.0.1:8081"), + // Normalised ONCE, here, so no request site can build a URL its own + // way. A bare host:port stays plain HTTP, which is what every + // existing config has; https:// now works instead of being pasted + // inside another scheme. + rpcaddr: crate::rpc_http::base_url(&ini_must(sec, "connect", "127.0.0.1:8081")), api_token: ini_must(sec, "api_token", "").trim().to_string(), pool_worker: ini_must(sec, "pool_worker", "").trim().to_string(), supervene: configured_supervene, @@ -132,7 +128,9 @@ impl PoWorkConf { /// Minimal config for integration tests. pub fn test_defaults(rpcaddr: String, supervene: u32, noncemax: u32) -> PoWorkConf { let mut cnf = PoWorkConf::new(&IniObj::new()); - cnf.rpcaddr = rpcaddr; + // Through the same normaliser the config load uses, so a test cannot + // exercise a URL shape the real path can never produce. + cnf.rpcaddr = crate::rpc_http::base_url(&rpcaddr); cnf.supervene = supervene; cnf.noncemax = noncemax; cnf.useopencl = false; @@ -154,6 +152,13 @@ pub fn poworker() { let config_path = sys::resolve_config_path(default_config); let inicnf = sys::load_config_path(&config_path); let cnf = PoWorkConf::new(&inicnf); + // Said once, at startup, where an operator will see it. Plaintext to a pool + // off this machine is not merely unencrypted: a pool credits a share to + // whatever payout address the request names, so anyone on the path can + // resend this rig's work under their own address and be paid for it. + if let Some(why) = crate::rpc_http::plaintext_warning(&cnf.rpcaddr) { + eprintln!("{why}"); + } // Mainnet-representative (x16rs repeat=16) benchmark. Runs only when // HACASH_REPEAT16_BENCH_SECONDS is set to a positive integer, then exits. // Zero-touch when the variable is unset; see bench_mainnet_repeat16.rs. @@ -284,7 +289,7 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf, stop_flag: &Option // query pending let urlapi_pending = format!( - "http://{}/query/miner/pending?stuff=true&t={}{}", + "{}/query/miner/pending?stuff=true&t={}{}", &cnf.rpcaddr, sys::curtimes(), cnf.worker_param() @@ -295,7 +300,8 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf, stop_flag: &Option Err(e) => { wlogln!( "Error: cannot get block data at {}: {}\n", - &urlapi_pending, e + &urlapi_pending, + e ); delay_return!(30); } @@ -359,7 +365,7 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf, stop_flag: &Option delay_return!(1); } let urlapi_notice = format!( - "http://{}/query/miner/notice?wait={}&height={}&rqid={}", + "{}/query/miner/notice?wait={}&height={}&rqid={}", &cnf.rpcaddr, &poll_wait, pending_height, @@ -376,7 +382,8 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf, stop_flag: &Option Err(e) => { wlogln!( "Error: cannot get miner notice at {}: {}\n", - &urlapi_notice, e + &urlapi_notice, + e ); delay_return!(10); } @@ -416,7 +423,7 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf, stop_flag: &Option fn push_block_mining_success(cnf: &PoWorkConf, success: &block_mining_runtime::BlockMiningResult) { let urlapi_success = format!( - "http://{}/submit/miner/success?height={}&block_nonce={}&coinbase_nonce={}&t={}{}", + "{}/submit/miner/success?height={}&block_nonce={}&coinbase_nonce={}&t={}{}", &cnf.rpcaddr, success.height, success.head_nonce, @@ -460,7 +467,9 @@ fn push_block_mining_success(cnf: &PoWorkConf, success: &block_mining_runtime::B let snippet: String = body.chars().take(120).collect(); wlogln!( "[submit] attempt {}/{} unrecognized response, retrying: {}", - attempt, MAX_SUBMIT_ATTEMPTS, snippet + attempt, + MAX_SUBMIT_ATTEMPTS, + snippet ); if attempt < MAX_SUBMIT_ATTEMPTS { std::thread::sleep(Duration::from_millis(500u64 * attempt as u64)); @@ -472,7 +481,8 @@ fn push_block_mining_success(cnf: &PoWorkConf, success: &block_mining_runtime::B last = format!("transport error: {e}"); wlogln!( "[submit] attempt {}/{} failed: {e}", - attempt, MAX_SUBMIT_ATTEMPTS + attempt, + MAX_SUBMIT_ATTEMPTS ); if attempt < MAX_SUBMIT_ATTEMPTS { std::thread::sleep(Duration::from_millis(500u64 * attempt as u64)); @@ -497,540 +507,435 @@ fn push_block_mining_success(cnf: &PoWorkConf, success: &block_mining_runtime::B } wlogln!("▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔") } -#[cfg(feature = "ocl")] -const AUTOTUNE_WARMUP_BATCHES: u32 = 3; -#[cfg(any(feature = "ocl", test))] -const AUTOTUNE_MIN_VALID_SAMPLES: u32 = 5; -#[cfg(any(feature = "ocl", test))] -const AUTOTUNE_ECO_MIN_PERFORMANCE_RATIO: f64 = 0.70; -#[cfg(any(feature = "ocl", test))] -const AUTOTUNE_VERIFY_MIN_HPS_RATIO: f64 = 0.70; - -#[cfg(any(feature = "ocl", test))] -#[derive(Clone, Copy, Debug, PartialEq)] -struct BenchmarkMeasurement { - hps: f64, - samples: u32, -} - -#[cfg(any(feature = "ocl", test))] -fn finish_benchmark_measurement( - total_hashes: u64, - total_secs: f64, - samples: u32, -) -> Result { - if total_hashes == 0 { - return Err("zero hashes measured".to_string()); - } - if samples < AUTOTUNE_MIN_VALID_SAMPLES { - return Err(format!( - "only {samples} valid samples (minimum {AUTOTUNE_MIN_VALID_SAMPLES})" - )); - } - if !total_secs.is_finite() || total_secs <= 0.0 { - return Err("invalid measured duration".to_string()); - } - let hps = total_hashes as f64 / total_secs; - if !hps.is_finite() || hps <= 0.0 { - return Err("zero or invalid hashrate".to_string()); +/// How many rank thresholds each candidate's equivalence proof uses. +/// +/// Each threshold is one launch that reads every hash in the window, and the +/// set of them catches ONE wrong hash anywhere in the window with probability +/// 1 - p, where p is printed by the tuner. 31 keeps a candidate's proof under a +/// second on this card while leaving a shape bug, which corrupts many hashes and +/// not one, with no way through: the miss probabilities multiply. +#[cfg(any(feature = "ocl", feature = "cuda"))] +const AUTOTUNE_PROOF_THRESHOLDS: u32 = 31; + +/// Corpus headers. Four distinct block intros, so the tune cannot be an artefact +/// of one header's algorithm chain. +#[cfg(any(feature = "ocl", feature = "cuda"))] +const AUTOTUNE_CORPUS_HEADERS: u32 = 4; + +/// What a hash is worth per day, read from the node the miner is configured to +/// mine on, or `None` when that cannot be known honestly. +/// +/// Only Profit mode needs it, and only to compare candidates. It is deliberately +/// not attempted when a pool is configured: a pool serves a SHARE target, which +/// is orders of magnitude weaker than the network target, so pricing a hash from +/// it would overstate revenue enormously and make every extra watt look free. +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn network_hac_per_hps_day(cnf: &PoWorkConf) -> Option { + if !cnf.pool_worker.is_empty() { + wlogln!( + "[autotune] pool_worker is set, so the target this miner is served is a share target, \ + not the network's. The value of a hash cannot be read from it." + ); + return None; } - Ok(BenchmarkMeasurement { hps, samples }) + let url = format!( + "{}/query/miner/pending?stuff=true&t={}", + &cnf.rpcaddr, + sys::curtimes() + ); + let body = crate::rpc_http::get_text(&HTTP_CLIENT, &url, &cnf.api_token, None).ok()?; + let res: JV = serde_json::from_str(&body).ok()?; + let height = res["height"].as_u64()?; + let target = hex::decode(res["target_hash"].as_str()?).ok()?; + let value = block_mining_runtime::hac_per_day_per_hashrate(height, &target)?; + wlogln!( + "[autotune] value of a hash, from {} at height {}: {:.3e} HAC per H/s per day", + &cnf.rpcaddr, + height, + value + ); + Some(value) } -#[cfg(any(feature = "ocl", test))] -fn x16rs_algorithm_id(hash: &[u8; 32]) -> u8 { - (u32::from_le_bytes([hash[28], hash[29], hash[30], hash[31]]) % 16) as u8 +/// Which backend a tune has to measure, decided by the same rule the MINER is +/// built from. +/// +/// `build_gpu_backends` prefers CUDA (`if cnf.usecuda { .. } else if +/// cnf.useopencl { .. }`) and hands `cnf.workgroups` / `cnf.unitsize` to +/// `initialize_cuda`, while `apply_benchmark_pick` writes exactly those two +/// numbers. So the tuner must measure whatever the next run will mine on, and +/// never the other backend: a tune that ran on OpenCL with `use_cuda = true` +/// still in the file would land its winner in the numbers the CUDA miner is +/// built from, and the two grids are three orders of magnitude apart (the +/// shipped CUDA config starts at 131072 x 256 x 8; the OpenCL grid tops out in +/// the low thousands of work groups). +/// +/// This used to be a refusal. It is now a route, because both backends can be +/// measured. +fn tuning_backend(cnf: &PoWorkConf) -> Option<&'static str> { + if cnf.usecuda { + Some("cuda") + } else if cnf.useopencl { + Some("opencl") + } else { + None + } } -#[cfg(any(feature = "ocl", test))] -fn validate_benchmark_batch_result( - height: u64, - block_intro: &[u8], - nonce_start: u32, - batch: u32, - result_nonce: u32, - result_hash: &[u8; 32], -) -> Result<(), String> { - if batch == 0 { - return Err("zero-size OpenCL batch".to_string()); - } - if result_hash.iter().all(|byte| *byte == 0) { - return Err("OpenCL returned a zero hash result".to_string()); - } - if *result_hash == [u8::MAX; 32] { - return Err("OpenCL returned no best hash".to_string()); - } - if result_nonce.wrapping_sub(nonce_start) >= batch { - return Err(format!( - "OpenCL returned nonce {result_nonce} outside batch starting at {nonce_start}" - )); - } - if block_intro.len() < 83 { - return Err("benchmark block intro is too short".to_string()); - } - let mut verify_intro = block_intro.to_vec(); - verify_intro[79..83].copy_from_slice(&result_nonce.to_be_bytes()); - let expected_hash = x16rs::block_hash(height, &verify_intro); - if expected_hash != *result_hash { - let prehash = x16rs::calculate_hash(&verify_intro); - return Err(format!( - "OpenCL nonce/hash result failed CPU verification: nonce={result_nonce} algorithm={} gpu={} cpu={}", - x16rs_algorithm_id(&prehash), - hex::encode(result_hash), - hex::encode(expected_hash) - )); +/// Everything the tuner needs about money, which is the same on both backends. +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn autotune_economics(cnf: &PoWorkConf) -> crate::autotune16::Economics { + crate::autotune16::Economics { + power_cost_kwh: cnf.efficiency.power_cost_kwh, + hac_price: cnf.efficiency.hac_price, + hac_per_hps_day: if cnf.efficiency.mode == EfficiencyMode::Profit { + network_hac_per_hps_day(cnf) + } else { + None + }, + cpu_watts: cnf.efficiency.initial_active_supervene(cnf.supervene) as f64 + * cnf.efficiency.cpu_watts_per_thread, } - Ok(()) } -#[cfg(feature = "ocl")] -fn execute_benchmark_batch( - opencl: &crate::opencl_gpu::OpenCLResources, - cnf: &PoWorkConf, - height: u64, - block_intro: &[u8], - nonce_start: u32, - batch: u32, - wg_eff: u32, - us: u32, -) -> Result { - let started = Instant::now(); - let (result_nonce, result_hash) = do_group_block_mining_opencl( - opencl, - height, - block_intro.to_vec(), - nonce_start, - wg_eff, - cnf.localsize, - us, - ) - .map_err(|error| error.display())?; - let used = started.elapsed().as_secs_f64(); - if !used.is_finite() || used <= 0.0 { - return Err("OpenCL batch returned an invalid duration".to_string()); - } - validate_benchmark_batch_result( - height, - block_intro, - nonce_start, - batch, - result_nonce, - &result_hash, - )?; - Ok(used) +/// The CPU oracle is what proves each shape, and it is the only part of a tune +/// that competes with the rest of the machine. Two cores are left alone so that +/// a node syncing beside the tuner keeps running. +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn autotune_oracle_threads() -> usize { + std::thread::available_parallelism() + .map(|n| n.get().saturating_sub(2).max(1)) + .unwrap_or(4) } -#[cfg(feature = "ocl")] -fn bench_block_hps( - opencl: &crate::opencl_gpu::OpenCLResources, - cnf: &PoWorkConf, - wg_eff: u32, - us: u32, - seconds: u64, -) -> Result { - let height = 1u64; - let block_intro = BlockIntro::default().serialize(); - let batch_u64 = (wg_eff as u64) - .saturating_mul(cnf.localsize as u64) - .saturating_mul(us as u64); - if batch_u64 == 0 || batch_u64 > u32::MAX as u64 { - return Err(format!( - "invalid launch size wg={wg_eff} local={} unit_size={us}", - cnf.localsize - )); +/// Report the tune and patch the ini, or say why it did not. +/// +/// Shared by both backends deliberately: "the card never settled, so the config +/// is unchanged" is a statement about the soak, not about the GPU API, and a +/// CUDA operator has to get exactly the same guarantee an OpenCL one does. +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn finish_tune( + config_path: &str, + request: &crate::autotune16::TuneRequest, + outcome: &crate::autotune16::TuneOutcome, +) { + wlogln!("\n================ AUTO TUNE (x16rs repeat = 16) ================"); + wlogln!("{}", crate::autotune16::render(outcome, request)); + if !outcome.settle.settled { + // The soak has already said which of the two it was, and they have + // different answers. A corpus that could not fit the settling window is + // a planning failure, and `plan_session` refuses those before the sweep, + // so reaching here means the card really was still moving. + wlogln!( + "[autotune] The card never settled, so this shape is not proven to sustain. Config \ + unchanged. The soak made {} passes over {:.0}s with the hashrate still spanning \ + {:.2}%; a larger benchmark_seconds buys a longer soak (up to 900s) and is the thing \ + to try.", + outcome.soak.len(), + outcome.soak_seconds, + outcome.settle.rate_span_pct, + ); + return; } - let batch = batch_u64 as u32; - let mut nonce = 0u32; - for warmup in 0..AUTOTUNE_WARMUP_BATCHES { - execute_benchmark_batch(opencl, cnf, height, &block_intro, nonce, batch, wg_eff, us) - .map_err(|error| format!("warm-up batch {} failed: {error}", warmup + 1))?; - nonce = nonce.wrapping_add(batch); + match apply_benchmark_pick(config_path, &outcome.pick) { + Ok(()) => { + wlogln!("[autotune] Config updated only after a settled soak at the chosen shape.") + } + Err(error) => wlogln!("[autotune] Could not patch ini: {error}"), } +} - let deadline = Instant::now() + Duration::from_secs(seconds.max(3)); - let mut total_hashes = 0u64; - let mut total_secs = 0.0f64; - let mut success_batches = 0u32; - while Instant::now() < deadline { - let used = - execute_benchmark_batch(opencl, cnf, height, &block_intro, nonce, batch, wg_eff, us) - .map_err(|error| { - format!("measured batch {} failed: {error}", success_batches + 1) - })?; - success_batches += 1; - total_hashes = total_hashes.saturating_add(batch as u64); - total_secs += used; - nonce = nonce.wrapping_add(batch); +fn run_block_mining_benchmark(cnf: &PoWorkConf, config_path: &str) { + match tuning_backend(cnf) { + Some("cuda") => run_cuda_benchmark(cnf, config_path), + Some("opencl") => run_opencl_benchmark(cnf, config_path), + _ => wlogln!( + "[autotune] Neither [gpu] use_cuda nor [gpu] use_opencl is set, so there is no GPU \ + backend to measure. Config unchanged." + ), } - finish_benchmark_measurement(total_hashes, total_secs, success_batches) } -#[cfg(any(feature = "ocl", test))] -#[derive(Clone, Debug)] -struct ProfileBenchResult { - pick: BenchmarkPick, - hps: f64, - estimated_watts: f64, - estimated_kh_per_j: f64, - samples: u32, +/// Auto Tune on a build with no CUDA backend at all. +/// +/// The message names CUDA, because that is what the operator asked for. The +/// version this replaced told a CUDA owner to enable OpenCL, a backend they had +/// deliberately turned off, and never mentioned CUDA at all. +#[cfg(not(feature = "cuda"))] +fn run_cuda_benchmark(cnf: &PoWorkConf, config_path: &str) { + let _ = (cnf, config_path); + wlogln!( + "[autotune] This config has [gpu] use_cuda = true, so the miner runs on CUDA, but THIS \ + BINARY was built without the CUDA backend and has no way to open an NVIDIA device or to \ + measure one. Nothing was measured and the config is unchanged. Rebuild with \ + `cargo build --release --features cuda` (the CUDA Toolkit must be installed and CUDA_PATH \ + set, or the build produces a binary with the feature and no kernels), or set use_cuda = \ + false and use_opencl = true to tune the OpenCL backend instead." + ); } -#[cfg(any(feature = "ocl", test))] -impl ProfileBenchResult { - fn new(pick: BenchmarkPick, hps: f64, estimated_watts: f64, samples: u32) -> Option { - if !hps.is_finite() - || hps <= 0.0 - || !estimated_watts.is_finite() - || estimated_watts <= 0.0 - || samples < AUTOTUNE_MIN_VALID_SAMPLES - { - return None; - } - Some(Self { - pick, - hps, - estimated_watts, - estimated_kh_per_j: hps / estimated_watts / 1000.0, - samples, - }) +/// Auto Tune on CUDA. Same tuner, same corpus, same proof, same soak. +#[cfg(feature = "cuda")] +fn run_cuda_benchmark(cnf: &PoWorkConf, config_path: &str) { + // The kernels, not the feature. `--features cuda` only adds the crate; + // whether it holds compiled kernels was decided by its build script finding + // nvcc, and without them every device call returns NotCompiled. Said in + // words about nvcc rather than as a driver error. + if !crate::x16rs_gate::cuda_kernels_available() { + wlogln!( + "[autotune] This binary has the cuda feature but NO CUDA KERNELS: x16rs-cuda's build \ + script did not find nvcc when it was built, so cfg(cuda_available) was never set and \ + every CUDA call returns `x16rs-cuda built without CUDA kernels`. Install the CUDA \ + Toolkit, set CUDA_PATH, and rebuild; the build prints `Using CUDA Toolkit at ...` \ + when it found one. Nothing was measured and the config is unchanged." + ); + return; } -} -#[cfg(any(feature = "ocl", test))] -fn pick_benchmark_result( - results: &[ProfileBenchResult], - mode: EfficiencyMode, -) -> Option<&ProfileBenchResult> { - let stable = || { - results.iter().filter(|result| { - result.hps.is_finite() - && result.hps > 0.0 - && result.estimated_watts.is_finite() - && result.estimated_watts > 0.0 - && result.estimated_kh_per_j.is_finite() - && result.estimated_kh_per_j > 0.0 - && result.samples >= AUTOTUNE_MIN_VALID_SAMPLES - }) - }; - match mode { - EfficiencyMode::Max => stable().max_by(|a, b| a.hps.total_cmp(&b.hps)), - EfficiencyMode::Profit => { - stable().max_by(|a, b| a.estimated_kh_per_j.total_cmp(&b.estimated_kh_per_j)) - } - EfficiencyMode::Eco => { - let max_hps = stable().map(|result| result.hps).fold(0.0f64, f64::max); - let minimum_hps = max_hps * AUTOTUNE_ECO_MIN_PERFORMANCE_RATIO; - stable() - .filter(|result| result.hps >= minimum_hps) - .min_by(|a, b| { - a.estimated_watts - .total_cmp(&b.estimated_watts) - .then_with(|| b.hps.total_cmp(&a.hps)) - }) + let devices = match x16rs_cuda::CudaMiner::list_devices() { + Ok(devices) => devices, + Err(error) => { + wlogln!( + "[autotune] No CUDA device could be enumerated: {error}. This is the driver or the \ + card, not the build: the kernels are compiled in. Check `nvidia-smi` runs, and \ + that the driver is not older than the CUDA runtime this binary was built \ + against. Nothing was measured and the config is unchanged." + ); + return; } + }; + if devices.is_empty() { + wlogln!( + "[autotune] The CUDA runtime reports zero devices on this machine. Nothing was \ + measured and the config is unchanged." + ); + return; } -} - -#[cfg(any(feature = "ocl", test))] -fn verification_is_stable(expected_hps: f64, verified_hps: f64) -> bool { - expected_hps.is_finite() - && expected_hps > 0.0 - && verified_hps.is_finite() - && verified_hps >= expected_hps * AUTOTUNE_VERIFY_MIN_HPS_RATIO -} - -#[cfg(any(feature = "ocl", test))] -fn verification_seconds(total_secs: u64) -> u64 { - (total_secs / 4).clamp(5, 15) -} - -#[cfg(any(feature = "ocl", test))] -fn autotune_device_count_is_supported(device_count: usize) -> bool { - device_count == 1 -} -#[cfg(feature = "ocl")] -fn benchmark_candidate( - opencl: &crate::opencl_gpu::OpenCLResources, - cnf: &PoWorkConf, - pick: BenchmarkPick, - seconds: u64, - max_workgroups: u32, - max_unitsize: u32, -) -> Result { - let measurement = bench_block_hps(opencl, cnf, pick.workgroups, pick.unitsize, seconds)?; - let estimated_watts = cnf.efficiency.estimate_tuning_watts( - &pick.profile, - pick.workgroups, - pick.unitsize, - max_workgroups, - max_unitsize, - ); - ProfileBenchResult::new(pick, measurement.hps, estimated_watts, measurement.samples) - .ok_or_else(|| "candidate produced an invalid measurement".to_string()) -} - -fn run_block_mining_benchmark(cnf: &PoWorkConf, config_path: &str) { - #[cfg(not(feature = "ocl"))] - { - let _ = (cnf, config_path); - wlogln!("[benchmark] Rebuild with --features ocl and use_opencl=true"); + if !devices.iter().any(|d| d.index == cnf.cudadevice) { + wlogln!( + "[autotune] [gpu] cuda_device = {} is not present. This machine has: {}. Set \ + cuda_device to one of those. Config unchanged.", + cnf.cudadevice, + devices + .iter() + .map(|d| format!("#{} {}", d.index, d.name)) + .collect::>() + .join(", ") + ); return; } - #[cfg(feature = "ocl")] - { - if !cnf.useopencl { - wlogln!("[benchmark] Set use_opencl=true in [gpu]"); + + let limits = match x16rs_cuda::CudaMiner::limits(cnf.cudadevice) { + Ok(limits) => limits, + Err(error) => { + wlogln!( + "[autotune] CUDA device #{} could not be probed: {error}. Config unchanged.", + cnf.cudadevice + ); return; } + }; + wlogln!("[autotune] CUDA {}", limits.describe()); + + // The block size is not a tuning axis on this backend and cannot be made + // one: `x16rs_cuda_main` declares `__shared__ unsigned int + // local_nonces[256]` and reduces over a power-of-two tree across the block, + // so 256 is the only block size it is correct at. Said out loud when the ini + // disagrees, because the alternative is a tune whose answer silently does + // not match the local_size the operator wrote. + let local_size = x16rs_cuda::DEFAULT_LOCAL_SIZE; + if cnf.localsize != local_size { wlogln!( - "[benchmark] Power and kH/J figures are estimates derived from configured board power; they are not hardware telemetry." + "[autotune] [gpu] local_size = {} is ignored on CUDA: block_miner.cu's shared \ + local_nonces[{local_size}] and its tree reduction are correct at {local_size} threads \ + a block and no other, so the tune measures {local_size}.", + cnf.localsize ); + } + if limits.kernel_max_threads_per_block > 0 + && (limits.kernel_max_threads_per_block as u32) < local_size + { wlogln!( - "[benchmark] NOTE: the MH/s below are raw X16RS repeat=1 tuning rates (relative comparison only). The live mainnet runs 16 rounds, so real block-hash throughput is roughly 1/11-1/16 of these numbers. For the honest mainnet figure run: set HACASH_REPEAT16_BENCH_SECONDS=30 and run poworker." + "[autotune] the batch kernel supports only {} threads a block on this device but its \ + reduction requires {local_size}. This device cannot run the CUDA miner at all, so \ + there is nothing to tune. Config unchanged.", + limits.kernel_max_threads_per_block ); - let total_secs = cnf.efficiency.benchmark_seconds.max(15) as u64; - let fine = cnf.efficiency.wants_fine_sweep(); - let profile_secs = if fine { - (total_secs * 70 / 100).max(20) - } else { - total_secs - }; - let sweep_secs = if fine { - total_secs.saturating_sub(profile_secs).max(10) - } else { - 0 - }; + return; + } - // Allocate GPU buffers for the largest profile unit_size (amd_max uses 128). - let init_unitsize = cnf.unitsize.max(128); + // The two ends of the work-group axis, both measured from the card. + // + // The floor is the smallest launch that puts a resident block on every + // multiprocessor: below it some SMs have no work and the hashrate describes + // the launch being too small rather than the shape. The ceiling is what the + // FREE device memory holds at the largest unit_size the grid explores, since + // a batch needs 36 bytes a nonce; it is then also capped by the operator's + // own [gpu] work_groups, exactly as the OpenCL path is capped by the device + // work-group count it was opened with. + let min_wg = limits.work_groups_that_fill_the_card(); + let memory_wg = limits.max_work_groups_for(CUDA_MAX_UNIT_SIZE, CUDA_TUNE_VRAM_SHARE); + let max_wg = memory_wg.min(cnf.workgroups.max(min_wg)).max(min_wg); + wlogln!( + "[autotune] work-group axis {min_wg}..{max_wg}: {min_wg} is one resident block on each of \ + the {} multiprocessors, {memory_wg} is what {:.0}% of the {:.1} GiB free fits at \ + unit_size {CUDA_MAX_UNIT_SIZE} ({} bytes a nonce), and [gpu] work_groups = {} caps it", + limits.device.multiprocessor_count, + CUDA_TUNE_VRAM_SHARE * 100.0, + limits.free_global_mem as f64 / (1024.0 * 1024.0 * 1024.0), + x16rs_cuda::DEVICE_BYTES_PER_NONCE, + cnf.workgroups, + ); + + // nvidia-smi's `-i` index and the CUDA device index are the same ordering on + // a default setup, so the sensor follows the card being tuned unless the + // operator named a different one in [efficiency] thermal_gpu_index. + let sensor_index = if cnf.efficiency.thermal_gpu_index != 0 { + cnf.efficiency.thermal_gpu_index + } else { + cnf.cudadevice.max(0) as u32 + }; + + let request = crate::autotune16::TuneRequest { + target: crate::autotune16::TuneTarget::Cuda { + device_index: cnf.cudadevice, + }, + local_size, + min_work_groups: min_wg, + max_work_groups: max_wg, + max_unit_size: CUDA_MAX_UNIT_SIZE, + vendor: crate::gpu_arch::GpuVendor::Nvidia, + mode: cnf.efficiency.mode, + budget_seconds: cnf.efficiency.benchmark_seconds.max(30) as u64, + economics: autotune_economics(cnf), + estimated_watts: cnf.efficiency.estimate_gpu_watts(&cnf.gpu_profile), + thermal_file: cnf.efficiency.thermal_file.clone(), + gpu_index: sensor_index, + oracle_threads: autotune_oracle_threads(), + headers: AUTOTUNE_CORPUS_HEADERS, + proof_thresholds: AUTOTUNE_PROOF_THRESHOLDS, + max_temp_c: (cnf.efficiency.max_temp_c > 0).then_some(cnf.efficiency.max_temp_c as f32), + }; + + match crate::autotune16::tune(&request) { + Ok(outcome) => finish_tune(config_path, &request, &outcome), + Err(error) => { + wlogln!("[autotune] REJECTED: {error}"); + wlogln!("[autotune] Config unchanged."); + } + } +} + +/// The largest `unit_size` the NVIDIA grid explores. +/// +/// 128 rather than the 192 the RDNA4 path allows, and the reason is a +/// measurement rather than caution. On a Tesla T4 at repeat 16, work_groups 256 +/// and local_size 256: unit_size 64 gave 7.54 MH/s, 96 gave 7.19 and 128 gave +/// 7.06. That is the REVERSE of the RX 9070 XT, where the kernel is latency +/// bound and 192 beats 64 by about 9%, and the reason is that the T4 sat at 66 +/// to 67 W against a 70 W cap: a bigger batch cannot buy more work on a card +/// already at its power limit. So on this vendor the optimum is at the SMALL end +/// and the grid's job is to bracket it, which 32..128 does from both sides. +/// +/// It is also what `ArchLimits::max_unit_size()` gives every non-gfx1201 slug, +/// so a shape written by this tune stays inside the range the rest of the config +/// and the panel already understand. +#[cfg(feature = "cuda")] +const CUDA_MAX_UNIT_SIZE: u32 = 128; + +/// Share of FREE device memory the CUDA candidate grid may size itself to. +/// +/// Not 1.0 and not close to it: cudaMalloc needs contiguous space, a display +/// driver on the same card takes memory `cudaMemGetInfo` has already counted as +/// free, and the tuner opens the winner's own miner for the soak while the +/// sweep's allocation is still being released. A grid sized to the last free +/// byte would spend the sweep watching allocations fail on exactly the largest +/// shapes, which are the ones the latency ceiling was going to drop anyway. +#[cfg(feature = "cuda")] +const CUDA_TUNE_VRAM_SHARE: f64 = 0.55; + +#[cfg(not(feature = "ocl"))] +fn run_opencl_benchmark(cnf: &PoWorkConf, config_path: &str) { + let _ = (cnf, config_path); + wlogln!( + "[autotune] This config has [gpu] use_opencl = true, but this binary was built without the \ + OpenCL backend. Rebuild with `cargo build --release --features ocl`. Config unchanged." + ); +} + +#[cfg(feature = "ocl")] +fn run_opencl_benchmark(cnf: &PoWorkConf, config_path: &str) { + { let scan = crate::opencl_diag::scan_opencl(); - let opencl_resources = initialize_opencl( + // One probe open, purely to learn this device's hard limits. Everything + // measured afterwards happens inside the tuner, which opens the device + // itself at the shapes it needs. + let probe = initialize_opencl( false, &cnf.opencldir, &cnf.platformid, &cnf.deviceids, &cnf.workgroups, &cnf.localsize, - &init_unitsize, + &cnf.unitsize.max(32), Some(&scan), false, ); - if opencl_resources.is_empty() { - wlogln!("[benchmark] No OpenCL devices"); - return; - } - if !autotune_device_count_is_supported(opencl_resources.len()) { + if probe.is_empty() { wlogln!( - "[benchmark] Auto Tune requires exactly one OpenCL device, but detected {}. The current config has one shared work_groups/unit_size pair, so multi-GPU tuning would be ambiguous. Set [gpu] device_ids to one device and tune each GPU separately; config unchanged.", - opencl_resources.len() + "[autotune] No OpenCL device could be opened (platform {}, device_ids '{}', \ + kernels from '{}'). Nothing was measured and the config is unchanged.", + cnf.platformid, + cnf.deviceids, + cnf.opencldir, ); return; } - - for (dev_i, opencl) in opencl_resources.iter().enumerate() { - let limits = crate::gpu_arch::ArchLimits::for_slug(&opencl.arch_slug); - let min_wg = limits.panel_min_wg.min(opencl.workgroups); - let max_wg = opencl.workgroups; - let max_us = limits - .max_unit_size() - .min(opencl.allocated_unitsize) - .max(32); - let max_tier = crate::gpu_arch::ArchLimits::panel_max_tier(&cnf.gpu_slug); - let candidates = benchmark_candidates_for_device( - opencl.vendor, - min_profile_tier_for_mode(cnf.efficiency.mode), - max_tier, - min_wg, - max_wg, - max_us, - ); - if candidates.is_empty() { - wlogln!( - "[benchmark] No safe tuning candidates for device #{}", - dev_i - ); - continue; - } - let per = (profile_secs / candidates.len() as u64).max(4); + if probe.len() != 1 { wlogln!( - "[benchmark] Device #{}: {}s x {} exact tuning points{}", - dev_i, - per, - candidates.len(), - if fine { " + bounded fine sweep" } else { "" } + "[autotune] Auto Tune requires exactly one OpenCL device, but detected {}. The \ + config has one shared work_groups/unit_size pair, so multi-GPU tuning would be \ + ambiguous. Set [gpu] device_ids to one device and tune each GPU separately; \ + config unchanged.", + probe.len() ); + return; + } + let limits = crate::gpu_arch::ArchLimits::for_slug(&probe[0].arch_slug); + let min_wg = limits.panel_min_wg.min(probe[0].workgroups); + let max_wg = probe[0].workgroups; + let max_us = limits.max_unit_size().max(32); + let vendor = probe[0].vendor; + drop(probe); + + let request = crate::autotune16::TuneRequest { + target: crate::autotune16::TuneTarget::OpenCl { + opencl_dir: cnf.opencldir.clone(), + platform: cnf.platformid, + device_ids: cnf.deviceids.clone(), + }, + local_size: cnf.localsize, + min_work_groups: min_wg, + max_work_groups: max_wg, + max_unit_size: max_us, + vendor, + mode: cnf.efficiency.mode, + budget_seconds: cnf.efficiency.benchmark_seconds.max(30) as u64, + economics: autotune_economics(cnf), + estimated_watts: cnf.efficiency.estimate_gpu_watts(&cnf.gpu_profile), + thermal_file: cnf.efficiency.thermal_file.clone(), + gpu_index: cnf.efficiency.thermal_gpu_index, + oracle_threads: autotune_oracle_threads(), + headers: AUTOTUNE_CORPUS_HEADERS, + proof_thresholds: AUTOTUNE_PROOF_THRESHOLDS, + max_temp_c: (cnf.efficiency.max_temp_c > 0).then_some(cnf.efficiency.max_temp_c as f32), + }; - let mut bench_results = Vec::new(); - for pick in candidates { - match benchmark_candidate(opencl, cnf, pick.clone(), per, max_wg, max_us) { - Ok(result) => { - wlogln!( - "[benchmark] dev{} {}: {} (estimated {:.1} kH/J @ {:.0}W, {} samples, wg={}, unit_size={})", - dev_i, - result.pick.profile, - rates_to_show(result.hps), - result.estimated_kh_per_j, - result.estimated_watts, - result.samples, - result.pick.workgroups, - result.pick.unitsize - ); - bench_results.push(result); - } - Err(error) => { - wlogln!( - "[benchmark] dev{} {}: REJECTED ({error}, wg={}, unit_size={})", - dev_i, pick.profile, pick.workgroups, pick.unitsize - ); - } - } - } - - let Some(base) = pick_benchmark_result(&bench_results, cnf.efficiency.mode) else { - wlogln!( - "[benchmark] No successful tuning points; config unchanged (check OpenCL driver)." - ); - continue; - }; - let mut selected = base.clone(); - - if fine && sweep_secs > 0 { - let wg_sweep_secs = sweep_secs / 2; - let us_sweep_secs = sweep_secs.saturating_sub(wg_sweep_secs).max(6); - let wg_candidates = sweep_workgroup_candidates_bounded( - selected.pick.workgroups, - opencl.vram_bytes, - cnf.localsize, - selected.pick.unitsize, - min_wg, - max_wg, - ); - let per_wg = (wg_sweep_secs / wg_candidates.len().max(1) as u64).max(3); - wlogln!( - "[benchmark] dev{} bounded wg sweep: {:?} x {}s", - dev_i, wg_candidates, per_wg - ); - let mut wg_results = Vec::new(); - for wg_try in wg_candidates { - let candidate = BenchmarkPick { - profile: selected.pick.profile.clone(), - workgroups: wg_try, - unitsize: selected.pick.unitsize, - }; - match benchmark_candidate(opencl, cnf, candidate, per_wg, max_wg, max_us) { - Ok(result) => { - wlogln!( - "[benchmark] dev{} wg={}: {} (estimated {:.1} kH/J @ {:.0}W, {} samples)", - dev_i, - wg_try, - rates_to_show(result.hps), - result.estimated_kh_per_j, - result.estimated_watts, - result.samples - ); - wg_results.push(result); - } - Err(error) => { - wlogln!("[benchmark] dev{} wg={}: REJECTED ({error})", dev_i, wg_try); - } - } - } - if let Some(best) = pick_benchmark_result(&wg_results, cnf.efficiency.mode) { - selected = best.clone(); - } - - let us_candidates = sweep_unitsize_candidates(selected.pick.unitsize, max_us); - let per_us = (us_sweep_secs / us_candidates.len().max(1) as u64).max(3); - wlogln!( - "[benchmark] dev{} bounded unit_size sweep: {:?} x {}s", - dev_i, us_candidates, per_us - ); - let mut us_results = Vec::new(); - for us_try in us_candidates { - let candidate = BenchmarkPick { - profile: selected.pick.profile.clone(), - workgroups: selected.pick.workgroups, - unitsize: us_try, - }; - match benchmark_candidate(opencl, cnf, candidate, per_us, max_wg, max_us) { - Ok(result) => { - wlogln!( - "[benchmark] dev{} unit_size={}: {} (estimated {:.1} kH/J @ {:.0}W, {} samples)", - dev_i, - us_try, - rates_to_show(result.hps), - result.estimated_kh_per_j, - result.estimated_watts, - result.samples - ); - us_results.push(result); - } - Err(error) => { - wlogln!( - "[benchmark] dev{} unit_size={}: REJECTED ({error})", - dev_i, us_try - ); - } - } - } - if let Some(best) = pick_benchmark_result(&us_results, cnf.efficiency.mode) { - selected = best.clone(); - } - } - - let verify_secs = verification_seconds(total_secs); - wlogln!( - "[benchmark] dev{} final verification soak: {}s at wg={} unit_size={}", - dev_i, verify_secs, selected.pick.workgroups, selected.pick.unitsize - ); - let verified = match benchmark_candidate( - opencl, - cnf, - selected.pick.clone(), - verify_secs, - max_wg, - max_us, - ) { - Ok(result) => result, - Err(error) => { - wlogln!( - "[benchmark] dev{} final verification REJECTED ({error}) - config unchanged.", - dev_i - ); - continue; - } - }; - if !verification_is_stable(selected.hps, verified.hps) { - wlogln!( - "[benchmark] dev{} final verification REJECTED: {} is below {:.0}% of measured {} - config unchanged.", - dev_i, - rates_to_show(verified.hps), - AUTOTUNE_VERIFY_MIN_HPS_RATIO * 100.0, - rates_to_show(selected.hps) - ); - continue; - } - wlogln!( - "[benchmark] dev{} verified: profile={} work_groups={} unit_size={} {} (estimated {:.1} kH/J @ {:.0}W, {} samples, mode={})", - dev_i, - verified.pick.profile, - verified.pick.workgroups, - verified.pick.unitsize, - rates_to_show(verified.hps), - verified.estimated_kh_per_j, - verified.estimated_watts, - verified.samples, - cnf.efficiency.mode.label() - ); - if dev_i == 0 { - match apply_benchmark_pick(config_path, &verified.pick) { - Ok(()) => { - wlogln!( - "[benchmark] Config updated only after successful final verification." - ) - } - Err(e) => wlogln!("[benchmark] Could not patch ini: {}", e), - } + match crate::autotune16::tune(&request) { + Ok(outcome) => finish_tune(config_path, &request, &outcome), + Err(error) => { + wlogln!("[autotune] REJECTED: {error}"); + wlogln!("[autotune] Config unchanged."); } } } @@ -1040,197 +945,164 @@ fn run_block_mining_benchmark(cnf: &PoWorkConf, config_path: &str) { mod tests { use super::*; - fn bench_result( - profile: &str, - workgroups: u32, - unitsize: u32, - hps: f64, - estimated_watts: f64, - ) -> ProfileBenchResult { - ProfileBenchResult::new( - BenchmarkPick { - profile: profile.to_string(), - workgroups, - unitsize, - }, - hps, - estimated_watts, - AUTOTUNE_MIN_VALID_SAMPLES, - ) - .unwrap() + /// The exact `[gpu]` section `scripts/mining-nvidia/INSTALL-CUDA-CONFIG.bat` + /// installs, kept here verbatim so this test tracks what a real NVIDIA + /// operator runs rather than a config invented for a test. + const CUDA_INI: &str = "\ +connect = 127.0.0.1:8080 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 45 + +[efficiency] +mode = profit +power_cost_kwh = 0.15 +gpu_watts = 0 +benchmark_seconds = 90 + +[gpu] +use_cuda = true +use_opencl = false +cuda_device = 0 +cpu_assist = true +work_groups = 131072 +local_size = 256 +unit_size = 8 +debug = 0 +"; + + fn temp_ini(tag: &str, body: &str) -> std::path::PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Relaxed); + let path = std::env::temp_dir().join(format!( + "hacash-{tag}-{}-{}.config.ini", + std::process::id(), + id + )); + std::fs::write(&path, body).unwrap(); + path } + /// Auto Tune on a CUDA rig is routed to the CUDA tuner, and a build that has + /// no CUDA backend says exactly that and touches nothing. + /// + /// This is the config the NVIDIA install script writes. The test suite runs + /// without `--features cuda`, so what it pins here is the honest refusal: no + /// device is opened, no OpenCL shape is measured, and the file is + /// byte-identical afterwards. `benchmark_seconds` therefore stays at 90, + /// which is what the panel reads back as "the benchmark did not produce a + /// valid profile", rather than a config quietly filled with numbers measured + /// on a backend this rig does not use. #[test] - fn autotune_pick_preserves_the_exact_measured_values() { - let results = vec![ - bench_result("amd_profit", 1024, 96, 100.0, 100.0), - bench_result("amd_performance", 1536, 64, 120.0, 150.0), - ]; - assert_eq!( - pick_benchmark_result(&results, EfficiencyMode::Max) - .unwrap() - .pick, - results[1].pick - ); + fn a_cuda_config_is_routed_to_cuda_and_a_build_without_it_leaves_the_file_untouched() { + let path = temp_ini("cuda-autotune-route", CUDA_INI); + let cnf = PoWorkConf::new(&sys::load_config_path(&path)); + assert!(cnf.usecuda, "the shipped CUDA config selects CUDA"); + assert!(!cnf.useopencl, "the shipped CUDA config leaves OpenCL off"); + assert_eq!(cnf.efficiency.benchmark_seconds, 90); assert_eq!( - pick_benchmark_result(&results, EfficiencyMode::Profit) - .unwrap() - .pick, - results[0].pick + tuning_backend(&cnf), + Some("cuda"), + "a CUDA config must be measured on CUDA, never on OpenCL" ); - } - #[test] - fn autotune_selection_is_mode_aware() { - let results = vec![ - bench_result("amd_eco", 32, 32, 60.0, 20.0), - bench_result("amd_balanced", 48, 48, 75.0, 40.0), - bench_result("amd_performance", 64, 64, 100.0, 80.0), - ]; + run_block_mining_benchmark(&cnf, path.to_str().unwrap()); - assert_eq!( - pick_benchmark_result(&results, EfficiencyMode::Max) - .unwrap() - .pick - .profile, - "amd_performance" - ); - assert_eq!( - pick_benchmark_result(&results, EfficiencyMode::Profit) - .unwrap() - .pick - .profile, - "amd_eco" - ); - assert_eq!( - pick_benchmark_result(&results, EfficiencyMode::Eco) - .unwrap() - .pick - .profile, - "amd_balanced" - ); + // Byte-identical whichever way the build went: with no CUDA feature + // nothing could be measured, and with one nothing is written until a + // soak settles. + assert_eq!(std::fs::read_to_string(&path).unwrap(), CUDA_INI); + let _ = std::fs::remove_file(&path); } + /// The routing follows `use_cuda` first, and `use_opencl` only after it. + /// + /// This is the ordering `build_gpu_backends` uses to decide what the MINER + /// runs, and the tuner has to agree with it: the next test shows what + /// disagreeing costs. #[test] - fn autotune_rejects_zero_and_under_sampled_measurements() { - assert!(finish_benchmark_measurement(0, 1.0, 10).is_err()); - assert!( - finish_benchmark_measurement(1_000, 1.0, AUTOTUNE_MIN_VALID_SAMPLES.saturating_sub(1)) - .is_err() + fn the_tuner_measures_whichever_backend_the_miner_will_run() { + let opencl_ini = CUDA_INI + .replace("use_cuda = true", "use_cuda = false") + .replace("use_opencl = false", "use_opencl = true"); + let path = temp_ini("opencl-autotune-route", &opencl_ini); + let cnf = PoWorkConf::new(&sys::load_config_path(&path)); + assert!(!cnf.usecuda && cnf.useopencl); + assert_eq!(tuning_backend(&cnf), Some("opencl")); + let _ = std::fs::remove_file(&path); + + // Both flags on: CUDA still wins, because `build_gpu_backends` prefers + // CUDA and the shape a tune writes is the one the CUDA miner is then + // built from. + let both = CUDA_INI.replace("use_opencl = false", "use_opencl = true"); + let path = temp_ini("both-backends-autotune", &both); + let cnf = PoWorkConf::new(&sys::load_config_path(&path)); + assert!(cnf.usecuda && cnf.useopencl); + assert_eq!( + tuning_backend(&cnf), + Some("cuda"), + "with both backends enabled the miner runs CUDA, so the tune must measure CUDA" ); - let valid = finish_benchmark_measurement(1_000, 2.0, AUTOTUNE_MIN_VALID_SAMPLES).unwrap(); - assert_eq!(valid.hps, 500.0); - assert_eq!(valid.samples, AUTOTUNE_MIN_VALID_SAMPLES); - } - - #[test] - fn autotune_final_verification_requires_repeatable_hashrate() { - assert!(verification_is_stable(100.0, 70.0)); - assert!(!verification_is_stable(100.0, 69.99)); - assert!(!verification_is_stable(100.0, f64::NAN)); - assert_eq!(verification_seconds(15), 5); - assert_eq!(verification_seconds(60), 15); - assert_eq!(verification_seconds(600), 15); - } - - #[test] - fn autotune_rejects_ambiguous_multi_gpu_targets() { - assert!(!autotune_device_count_is_supported(0)); - assert!(autotune_device_count_is_supported(1)); - assert!(!autotune_device_count_is_supported(2)); + run_block_mining_benchmark(&cnf, path.to_str().unwrap()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), both); + let _ = std::fs::remove_file(&path); + + // Neither: nothing to measure, and nothing written. + let neither = CUDA_INI.replace("use_cuda = true", "use_cuda = false"); + let path = temp_ini("no-backend-autotune", &neither); + let cnf = PoWorkConf::new(&sys::load_config_path(&path)); + assert_eq!(tuning_backend(&cnf), None); + run_block_mining_benchmark(&cnf, path.to_str().unwrap()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), neither); + let _ = std::fs::remove_file(&path); } + /// Why the CUDA refusal has to come before the OpenCL gate. + /// + /// `build_gpu_backends` prefers CUDA (`if cnf.usecuda { .. } else if + /// cnf.useopencl { .. }`) and hands `cnf.workgroups` / `cnf.unitsize` to + /// `initialize_cuda`, while `apply_benchmark_pick` writes exactly those two + /// numbers. So a tune that ran on OpenCL with `use_cuda = true` still in the + /// file would land its winner in the numbers the CUDA miner is built from. + /// + /// The magnitudes below are the point: the shipped CUDA starting shape is + /// 131072 x 256 x 8, and the OpenCL tuner's search space tops out at a few + /// thousand work groups with unit_size in {32, 64, 128, 192}. This is not a + /// translation between backends, it is an overwrite, and it is what + /// `cuda_backend_refusal` now prevents by refusing on `use_cuda` rather than + /// letting `use_opencl` alone decide. #[test] - fn autotune_rejects_invalid_gpu_results_and_accepts_cpu_verified_result() { - let height = 1u64; - let block_intro = BlockIntro::default().serialize(); - let nonce_start = 11u32; - let batch = 256u32; - - assert!( - validate_benchmark_batch_result( - height, - &block_intro, - nonce_start, - batch, - nonce_start, - &[0u8; 32] - ) - .is_err() - ); - assert!( - validate_benchmark_batch_result( - height, - &block_intro, - nonce_start, - batch, - nonce_start, - &[u8::MAX; 32] - ) - .is_err() - ); - - let result_nonce = nonce_start + 42; - let mut verified_intro = block_intro.clone(); - verified_intro[79..83].copy_from_slice(&result_nonce.to_be_bytes()); - let result_hash = x16rs::block_hash(height, &verified_intro); - validate_benchmark_batch_result( - height, - &block_intro, - nonce_start, - batch, - result_nonce, - &result_hash, + fn an_opencl_tune_result_lands_in_the_two_numbers_the_cuda_miner_is_built_from() { + let both = CUDA_INI.replace("use_opencl = false", "use_opencl = true"); + let path = temp_ini("cuda-autotune-crossover", &both); + + let before = PoWorkConf::new(&sys::load_config_path(&path)); + assert!(before.usecuda && before.useopencl); + assert_eq!((before.workgroups, before.unitsize), (131072, 8)); + + // A plausible winner from an OpenCL sweep on an RTX 4070-class card. + apply_benchmark_pick( + path.to_str().unwrap(), + &BenchmarkPick { + profile: "nvidia_max".to_string(), + workgroups: 1536, + unitsize: 128, + }, ) .unwrap(); + let after = PoWorkConf::new(&sys::load_config_path(&path)); assert!( - validate_benchmark_batch_result( - height, - &block_intro, - nonce_start, - batch, - nonce_start + batch, - &result_hash - ) - .is_err() - ); - } - - #[test] - fn gfx1201_groestl_failure_vector_is_cpu_rejected() { - let height = 1u64; - let block_intro = BlockIntro::default().serialize(); - let result_nonce = 6_858_338u32; - let mut verified_intro = block_intro.clone(); - verified_intro[79..83].copy_from_slice(&result_nonce.to_be_bytes()); - let pre_x16rs = x16rs::calculate_hash(&verified_intro); - let expected = x16rs::block_hash(height, &verified_intro); - let bad_gpu_hash: [u8; 32] = - hex::decode("00004f8f9d0fd569407298186d7015bc19d70bd379a551190b7233135562cb33") - .unwrap() - .try_into() - .unwrap(); - - wlogln!( - "nonce={result_nonce} pre_x16rs={} algorithm={} expected_x16rs={} bad_gpu={}", - hex::encode(pre_x16rs), - x16rs_algorithm_id(&pre_x16rs), - hex::encode(expected), - hex::encode(bad_gpu_hash) + after.usecuda, + "the tune did not turn CUDA off; the next run still mines on CUDA" ); - assert_eq!(x16rs_algorithm_id(&pre_x16rs), 2); - assert!( - validate_benchmark_batch_result( - height, - &block_intro, - result_nonce, - 1, - result_nonce, - &bad_gpu_hash - ) - .is_err() + assert_eq!( + (after.workgroups, after.unitsize), + (1536, 128), + "the OpenCL winner is now what initialize_cuda(cnf.cudadevice, cnf.workgroups, cnf.unitsize) is given" ); + assert_eq!(after.efficiency.benchmark_seconds, 0); + let _ = std::fs::remove_file(&path); } #[test] @@ -1309,6 +1181,9 @@ mod tests { #[test] fn cpu_group_mining_result_matches_manual_scan() { + use field::Serialize; + use protocol::block::BlockIntro; + let height = 1u64; let block_intro = BlockIntro::default().serialize(); let nonce_start = 11u32; diff --git a/app/src/rpc_http.rs b/app/src/rpc_http.rs index 02dc4dae..89a8e0f9 100644 --- a/app/src/rpc_http.rs +++ b/app/src/rpc_http.rs @@ -21,6 +21,75 @@ pub fn build_client() -> Result { .build() } +/// Turn a `connect` config value into the base URL every request is built on. +/// +/// The miners used to paste this value straight into `format!("http://{}/...")`, +/// so a `connect = https://pool.example.org` produced +/// `http://https://pool.example.org/...` and the rig sat in a thirty-second +/// error loop for ever, with nothing anywhere naming the scheme as the cause. +/// Plaintext was not a choice an operator could decline. +/// +/// It matters beyond convenience. The pool credits a share to whatever address +/// the query string names, and the replay key does not include it, so anyone who +/// can SEE a submission can resend the same nonces under their own address and +/// take the credit. Over the public internet that requires only a position on +/// the path; over TLS it requires breaking TLS. +/// +/// Accepted, in order: +/// * `https://host[:port]` and `http://host[:port]`, used as given +/// * `host:port` - legacy, and still the common case, read as plain HTTP +/// +/// Any trailing slash is trimmed so callers can concatenate a path that starts +/// with one without producing a double. +pub fn base_url(connect: &str) -> String { + let c = connect.trim().trim_end_matches('/'); + if c.starts_with("http://") || c.starts_with("https://") { + c.to_string() + } else { + format!("http://{c}") + } +} + +/// Is this base URL plaintext to somewhere that is not this machine? +/// +/// `None` when there is nothing to say. The warning is deliberately about the +/// SHARE path rather than about privacy: an observer of plaintext traffic can +/// resend a miner's nonces under their own payout address, and the pool has no +/// way to tell the two apart. +pub fn plaintext_warning(base: &str) -> Option { + let rest = base.strip_prefix("http://")?; + let host = rest + .split('/') + .next() + .unwrap_or(rest) + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(rest); + let local = host == "localhost" + || host == "127.0.0.1" + || host == "::1" + || host == "[::1]" + || host.starts_with("10.") + || host.starts_with("192.168.") + || host.starts_with("172.16.") + || host.starts_with("172.17.") + || host.starts_with("172.18.") + || host.starts_with("172.19.") + || host.starts_with("172.2") + || host.starts_with("172.30.") + || host.starts_with("172.31."); + if local { + return None; + } + Some(format!( + "[connect] {base} is PLAIN HTTP to a host that is not this machine or your own network. \ + Anyone on the path can read this miner's submissions, and because a pool credits a \ + share to whatever payout address the request names, they can resend the same work \ + under THEIR address and be paid for it. If the pool offers https, use it: put \ + `connect = https://` in the config." + )) +} + pub fn apply_api_token(mut req: RequestBuilder, api_token: &str) -> RequestBuilder { let token = api_token.trim(); if token.is_empty() { @@ -81,7 +150,7 @@ pub fn read_body_limited(resp: Response) -> Result { let declared_length = resp.content_length(); let body = read_limited(resp, declared_length)?; // reqwest returns Ok for ANY HTTP status. A 5xx (or 408/429) is a transient - // server/proxy failure, NOT an application reply — surface it as an error so + // server/proxy failure, NOT an application reply - surface it as an error so // callers retry (e.g. a winning block submit) instead of mistaking a 502 // error page for a response and dropping the block. Deterministic 4xx replies // are passed through so the caller can read the node's error body. @@ -129,4 +198,56 @@ mod tests { fn non_utf8_body_is_rejected() { assert!(read_limited(Cursor::new([0xff]), None).is_err()); } + + #[test] + fn a_connect_value_becomes_a_base_url_without_nesting_one_scheme_in_another() { + // The shape every existing config has, and it must keep working exactly + // as it did: a bare host:port is plain HTTP. + assert_eq!(base_url("127.0.0.1:8081"), "http://127.0.0.1:8081"); + assert_eq!(base_url(" 127.0.0.1:8081 "), "http://127.0.0.1:8081"); + + // The shape that used to be pasted INSIDE another scheme, producing + // http://https://pool.example.org/... and leaving the rig in a + // thirty-second error loop for ever with nothing naming the cause. + assert_eq!( + base_url("https://pool.example.org"), + "https://pool.example.org" + ); + assert_eq!(base_url("http://node.local:8080"), "http://node.local:8080"); + + // A trailing slash would otherwise double up against paths that start + // with one. + assert_eq!( + base_url("https://pool.example.org/"), + "https://pool.example.org" + ); + assert_eq!(base_url("127.0.0.1:8081/"), "http://127.0.0.1:8081"); + } + + #[test] + fn plaintext_is_only_worth_warning_about_when_it_leaves_the_machine() { + // Loopback and private ranges are the ordinary, correct setup: the node + // or pool is on this box or this LAN, and there is no path to sit on. + for quiet in [ + "http://127.0.0.1:8080", + "http://localhost:9777", + "http://192.168.1.50:9777", + "http://10.0.0.9:9777", + ] { + assert!( + plaintext_warning(quiet).is_none(), + "{quiet} should not warn" + ); + } + // TLS anywhere is fine by definition. + assert!(plaintext_warning("https://pool.example.org").is_none()); + + // Plain HTTP to somebody else's machine is the case that matters, and + // the warning has to say WHY - not "unencrypted" but "someone can be + // paid for your work", which is what actually happens. + let w = plaintext_warning("http://pool.example.org:9777") + .expect("plain http to a remote pool must warn"); + assert!(w.contains("resend the same work"), "{w}"); + assert!(w.contains("https://"), "and say what to do instead: {w}"); + } } diff --git a/app/src/worker_log.rs b/app/src/worker_log.rs index 39dbef6f..8f983bd7 100644 --- a/app/src/worker_log.rs +++ b/app/src/worker_log.rs @@ -283,17 +283,40 @@ mod tests { let path = log_path(&dir, "poworker"); init_with_limit(&path, DEFAULT_MAX_BYTES).expect("open the log"); - record("[Mining] first"); + // `serial()` only orders the tests in THIS module. The log is one + // process-wide file and every other test in the crate that reaches a + // `wlogln!` writes into whichever log is open, so counting the lines in + // the file counted their output too and passed only on lucky ordering. + // The claim being made is about the lines this test wrote, so they are + // tagged and picked out; nothing else in the crate can carry the tag. + let tag = format!( + "log-write-{}-{:?}", + std::process::id(), + std::thread::current().id() + ); + record(&format!("[Mining] {tag} first")); // A multi-line print becomes one entry per line: the panel splits on // newlines and must never see a half entry. - record("[Mining] second\r\n[Mining] third"); + record(&format!("[Mining] {tag} second\r\n[Mining] {tag} third")); let text = fs::read_to_string(&path).expect("read back"); - let lines: Vec<&str> = text.lines().collect(); + let lines: Vec<&str> = text.lines().filter(|line| line.contains(&tag)).collect(); assert_eq!(lines.len(), 3, "got {text:?}"); - assert!(lines[0].ends_with(" [Mining] first"), "{}", lines[0]); - assert!(lines[1].ends_with(" [Mining] second"), "{}", lines[1]); - assert!(lines[2].ends_with(" [Mining] third"), "{}", lines[2]); + assert!( + lines[0].ends_with(&format!(" [Mining] {tag} first")), + "{}", + lines[0] + ); + assert!( + lines[1].ends_with(&format!(" [Mining] {tag} second")), + "{}", + lines[1] + ); + assert!( + lines[2].ends_with(&format!(" [Mining] {tag} third")), + "{}", + lines[2] + ); // Every entry carries the time it was printed, so the panel shows a // measured timestamp instead of the moment it happened to read the file. assert_eq!(lines[0].chars().nth(4), Some('-'), "{}", lines[0]); diff --git a/app/src/x16rs_gate.rs b/app/src/x16rs_gate.rs new file mode 100644 index 00000000..cbd1b84b --- /dev/null +++ b/app/src/x16rs_gate.rs @@ -0,0 +1,2505 @@ +//! x16rs equivalence gate and fixed-corpus baseline. +//! +//! ADDITIVE MODULE. Nothing in the mining path calls it; it exists so that any +//! future change to a GPU kernel can be judged against two things that a +//! self-test cannot give you: +//! +//! 1. `equiv`, a byte-equivalence proof. A fixed corpus of block headers and +//! a fixed nonce window, hashed on the GPU and on the CPU, with EVERY 32 +//! byte result compared exactly, at x16rs repeat = 1, 4, 8 and 16. The +//! oracle is the CPU consensus implementation (`x16rs::block_hash`, i.e. +//! the x16rs-sys C reference), never another GPU build, so a bug that both +//! GPU builds share is still caught. +//! +//! 2. `baseline`, a FIXED-WORK timing run. x16rs per-hash cost varies by +//! algorithm, so a fixed-TIME benchmark silently compares different work +//! between runs. This one hashes an identical, deterministic nonce range +//! every time and reports the spread across repeated runs, which is the +//! noise floor any later "optimisation" has to beat to mean anything. +//! +//! ONE GATE, TWO BACKENDS. The corpus, the CPU oracle, the exhaustive-window +//! reassembly, the threshold arithmetic, the comparison, the algorithm coverage +//! counting and the blame attribution live once, in code that is not compiled +//! per backend. OpenCL and CUDA each supply only three device operations +//! ([`GateDevice`]) and a way to open a device at a given launch shape +//! ([`GateBackend`]). A gate that had been forked per backend would drift, and +//! the fork would be discovered by one of them passing something the other +//! catches. +//! +//! How `equiv` gets every hash off the card. The mining kernel returns only the +//! best hash per work group, which would prove one hash in a hundred thousand. +//! The pool share list (`x16rs_main.cl`, and the identical block in +//! `x16rs-cuda/cuda/block_miner.cu`, both keyed on `share_capacity != 0`) +//! already emits (nonce, hash) for every nonce that beats a target. With target +//! 0xff..ff every nonce qualifies, so a batch sized to exactly the share list's +//! capacity dumps its ENTIRE window. That is the exhaustive mode, and it is +//! available on BOTH backends today: the CUDA port carries the same 1024-entry +//! list, the same total-hit counter and the same append-before-reduce ordering. +//! The production launch shape (48 x 256 x 48 = 589 824 nonces) cannot fit in +//! the list, so there the gate checks the 1024 returned hashes byte for byte AND +//! checks that the kernel counted the whole window against a set of rank +//! thresholds. + +#[cfg(any(feature = "ocl", feature = "cuda"))] +use std::time::Instant; + +/// Height whose `block_hash_repeat` is the mainnet maximum, 16. +pub const REPEAT16_HEIGHT: u64 = 800_000; + +/// How far apart two SEPARATE invocations of this binary land on identical work. +/// +/// Measured on this rig by running `baseline` repeatedly over the fixed corpus: +/// within one process the runs reproduce to a few tenths of a percent, but the +/// card settles into one of several clock and power states for the LIFE of a +/// process, so two processes on the same work have disagreed by about this much. +/// +/// It is the bar for any claim that compares a number from one run against a +/// number from another run - a different build, a different day, a different +/// kernel - and it is stated once here because two things now quote it: the +/// baseline report, and the auto-tuner, whose CUDA path cannot fall back to the +/// in-process A/B that resolves ten times finer (`run_ab` is OpenCL-only, and +/// structurally so: nvcc compiles CUDA kernels into the binary). +pub const BETWEEN_PROCESS_SPREAD_PCT: f64 = 2.6; + +/// The 89-byte block intro layout the kernel and the CPU agree on. +pub const BLOCK_INTRO_BYTES: usize = 89; + +/// Byte offset of the 4-byte big-endian nonce inside the intro. +pub const NONCE_OFFSET: usize = 79; + +/// Heights that produce repeat = 1, 4, 8 and 16. `block_hash_repeat` is +/// `min(16, height / 50_000 + 1)`, so these are exact, not approximate. +pub const GATE_HEIGHTS: [u64; 4] = [1, 150_000, 350_000, 800_000]; + +/// Deterministic 89-byte block intro number `index`. +/// +/// Every byte is pseudo-random so that the corpus is not a family of near +/// identical inputs, but the stream is a fixed SplitMix64 seeded by `index`, so +/// two runs a month apart hash exactly the same bytes. +/// +/// Two constraints are not free choices: +/// * bytes 79..83 are the nonce and are overwritten per hash; +/// * byte 88 MUST be 0. `sha3_256.cl` folds the 89-byte message's padding into +/// a constant that pins byte 88 to 0x00 (see the comment at sha3_256.cl:154). +/// A real intro satisfies this because byte 88 is the low byte of +/// `witness_stage`. A corpus that ignored it would have the card and the CPU +/// hashing different messages, and the gate would report a kernel bug that +/// is really a harness bug. +pub fn corpus_header(index: u32) -> Vec { + let mut state = 0x9E3779B97F4A7C15u64 ^ ((index as u64).wrapping_mul(0xD1B54A32D192ED03)); + let mut next = || -> u64 { + state = state.wrapping_add(0x9E3779B97F4A7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + }; + let mut intro = vec![0u8; BLOCK_INTRO_BYTES]; + for chunk in intro.chunks_mut(8) { + let word = next().to_le_bytes(); + let take = chunk.len(); + chunk.copy_from_slice(&word[..take]); + } + intro[BLOCK_INTRO_BYTES - 1] = 0; + intro +} + +/// The CPU oracle for one nonce. This is the consensus hash, byte for byte. +pub fn cpu_hash(height: u64, intro: &[u8], nonce: u32) -> [u8; 32] { + let mut stuff = intro.to_vec(); + stuff[NONCE_OFFSET..NONCE_OFFSET + 4].copy_from_slice(&nonce.to_be_bytes()); + x16rs::block_hash(height, &stuff) +} + +/// CPU oracle for a whole contiguous nonce window, spread over `threads`. +/// +/// Threading changes nothing about the result: each nonce is independent and the +/// output is reassembled in nonce order. It changes a great deal about whether +/// the gate is run at all, so the cost is worth stating in numbers rather than +/// in adjectives. +/// +/// Measured in a release build on this machine, `x16rs::block_hash` at height +/// 800 000 (repeat = 16) runs at 78 630 hashes a second on one core. A caller +/// asking for a whole production window is therefore asking for +/// `window / CPU_ORACLE_HPS_PER_CORE` core-seconds and `window * 32` bytes twice +/// over: a 3.1 M-nonce window (64x256x192, the largest an RX 9070 XT can be +/// asked for) is under four seconds on fourteen threads and 200 MB, while a +/// 100 M-nonce window (3072x256x128, which a 3584 work-group preset permits) is +/// two minutes and 6.4 GB. That is why +/// `autotune16::plan_session` prunes the shapes whose batch cannot meet the +/// latency ceiling before anything proves them: the proof cost is linear in the +/// launch window, and the largest windows are the ones that could never have +/// been chosen anyway. +/// +/// The note that stood here said "a few hundred hashes a second per core". That +/// is not what this build does, and the gap matters: it is the difference +/// between a production-window proof being impossible and it being a hundred +/// seconds. +/// Hashes a second one core manages on `x16rs::block_hash` at repeat 16. +/// +/// Measured on this machine over 20 000 nonces: 78 630 H/s in a release build, +/// 16 268 H/s in a debug one. 60 000 is quoted rather than 78 000 because the +/// number's only job is to tell an operator how long a proof will take, the +/// machine running the tune is also mining, and an estimate that under-states +/// the wait is worse than one that over-states it. +pub const CPU_ORACLE_HPS_PER_CORE: f64 = 60_000.0; + +/// How long `cpu_hash_window` will take, and how much it will allocate, for a +/// window of `window` nonces on `threads` threads. +/// +/// The caller is the auto-tuner, which has to be able to tell the operator what +/// a tune will cost before it starts spending their time on it. +pub fn oracle_cost(window: u64, threads: usize) -> (f64, u64) { + let threads = threads.clamp(1, 256) as f64; + ( + window as f64 / (CPU_ORACLE_HPS_PER_CORE * threads), + // `cpu` and its sorted clone, 32 bytes a nonce each. + window.saturating_mul(64), + ) +} + +pub fn cpu_hash_window( + height: u64, + intro: &[u8], + nonce_start: u32, + count: u32, + threads: usize, +) -> Vec<[u8; 32]> { + let threads = threads.clamp(1, 256); + if threads == 1 || count < threads as u32 { + return (0..count) + .map(|i| cpu_hash(height, intro, nonce_start.wrapping_add(i))) + .collect(); + } + let mut out: Vec<[u8; 32]> = vec![[0u8; 32]; count as usize]; + let chunk = count.div_ceil(threads as u32) as usize; + std::thread::scope(|scope| { + for (slot, piece) in out.chunks_mut(chunk).enumerate() { + let base = nonce_start.wrapping_add((slot * chunk) as u32); + scope.spawn(move || { + for (i, cell) in piece.iter_mut().enumerate() { + *cell = cpu_hash(height, intro, base.wrapping_add(i as u32)); + } + }); + } + }); + out +} + +/// The algorithm index x16rs picks for each round, computed on the CPU. +/// +/// The reference picks `inputoutput[7] % 16` at the top of every round, where +/// `inputoutput` is the 32-byte state read as little-endian u32s. Word 7 is +/// bytes 28..32, so `% 16` is the low nibble of byte 28. Re-deriving it here +/// costs `repeat` extra CPU hashes per nonce and needs no change to the C +/// reference, which is the point: the oracle stays untouched. +pub fn algo_sequence(height: u64, intro: &[u8], nonce: u32) -> Vec { + let repeat = x16rs::block_hash_repeat(height); + let mut stuff = intro.to_vec(); + stuff[NONCE_OFFSET..NONCE_OFFSET + 4].copy_from_slice(&nonce.to_be_bytes()); + let seed = x16rs::calculate_hash(&stuff); + let mut seq = Vec::with_capacity(repeat as usize); + for round in 0..repeat { + let state = x16rs_sys_hash(round, &seed); + seq.push(state[28] & 0x0f); + } + seq +} + +/// `x16rs_hash` with an explicit loop count, including 0 (identity). +fn x16rs_sys_hash(loops: i32, input: &[u8; 32]) -> [u8; 32] { + x16rs::x16rs_hash(loops, input) +} + +/// One mismatch, with everything needed to reproduce it by hand. +#[derive(Clone, Debug)] +pub struct Mismatch { + pub height: u64, + pub repeat: i32, + pub header_index: u32, + pub nonce: u32, + pub gpu: [u8; 32], + pub cpu: [u8; 32], + /// Algorithm indices the CPU used, in round order. + pub algos: Vec, +} + +impl Mismatch { + pub fn render(&self) -> String { + let named: Vec = self + .algos + .iter() + .map(|a| format!("{}:{}", a, ALGO_NAMES[(*a & 0x0f) as usize])) + .collect(); + format!( + " MISMATCH height={} repeat={} header={} nonce={}\n gpu={}\n cpu={}\n cpu algo chain={}", + self.height, + self.repeat, + self.header_index, + self.nonce, + hex::encode(self.gpu), + hex::encode(self.cpu), + named.join(" -> "), + ) + } +} + +/// Which of the sixteen algorithms the mismatches point at. +/// +/// The reasoning is the only one the data actually supports, and it is worth +/// stating because a plausible-looking alternative is wrong. Each nonce runs a +/// chain of `repeat` algorithms chosen by its own hash. If exactly one algorithm +/// is broken then, necessarily: +/// +/// * EVERY mismatching nonce ran it, so it survives the intersection of all +/// failing chains; +/// * NO matching nonce ran it, because running it would have produced a wrong +/// hash, so it appears in none of the sampled passing chains. +/// +/// How much each half is worth, measured rather than assumed, by breaking each +/// of the sixteen algorithms in turn on this corpus at repeat 16: +/// +/// * past about eight failing chains the intersection alone is usually already +/// a single algorithm, and a real failing run is nowhere near that margin - +/// one broken algorithm at repeat 16 corrupts 1 - (15/16)^16 = 64% of every +/// window, so a default `equiv` fails thousands of nonces, not eight; +/// * between three and eight it is not. With three failing chains, breaking +/// skein left {groestl, jh, keccak, skein, cubehash, fugue} in the +/// intersection and breaking luffa left seven candidates; the passing-chain +/// filter cut both to the one true culprit. +/// +/// So the intersection carries a well-populated run and the passing filter +/// carries the thin one. Both are one pass over data the gate already has, and +/// dropping either would cost accuracy in exactly the case where the operator +/// most needs a name. +/// +/// A defect that is NOT one algorithm - a missing barrier, a bad shared-table +/// fill, a wrong nonce write - leaves no algorithm in every failing chain, and +/// this reports exactly that instead of inventing a culprit. That is the honest +/// outcome for the one of the three injected OpenCL faults that could not be +/// named. +#[derive(Clone, Debug, Default)] +pub struct AlgoAttribution { + /// Algorithms in every failing chain AND in no sampled passing chain. + pub named: Vec, + /// Algorithms in every failing chain, before the passing-chain filter. + pub in_every_failure: Vec, + /// Failing chains that ran each algorithm. + pub failing: [u64; 16], + /// Sampled passing chains that ran each algorithm. + pub passing: [u64; 16], + pub failing_chains: u64, + pub passing_chains: u64, +} + +/// Failing chains below which the gate refuses to name anything. +/// +/// An innocent algorithm survives the intersection of n failing chains with +/// probability about 0.64^n at repeat 16, which is 3% at n = 8, and it then has +/// to be absent from every passing chain as well before a wrong name is printed. +/// Measured on this corpus: every case that reached eight failing chains named +/// the true culprit alone, while at five chains shavite, hamsi and shabal were +/// indistinguishable from each other and all three came back as a tie. So under +/// 8 the gate prints candidates instead. A confident wrong name would send +/// someone to read the wrong kernel file for an afternoon, and the list costs +/// them nothing. +pub const MIN_CHAINS_TO_NAME: u64 = 8; + +impl AlgoAttribution { + pub fn render(&self) -> String { + if self.failing_chains == 0 { + return String::new(); + } + let mut text = String::new(); + let names = |set: &[u8]| -> String { + set.iter() + .map(|a| format!("{} ({})", ALGO_NAMES[(*a & 0x0f) as usize], a)) + .collect::>() + .join(", ") + }; + text.push_str(&format!( + " blame: {} failing chains, {} sampled passing chains\n", + self.failing_chains, self.passing_chains + )); + if self.failing_chains < MIN_CHAINS_TO_NAME { + text.push_str(&format!( + " too few failing chains to name an algorithm (need {MIN_CHAINS_TO_NAME}); \ + present in all of them: {}\n", + names(&self.in_every_failure) + )); + } else if self.named.len() == 1 { + text.push_str(&format!( + " ALGORITHM: {}. It ran in every one of the {} failing chains and in none \ + of the {} passing ones.\n", + names(&self.named), + self.failing_chains, + self.passing_chains + )); + } else if self.named.is_empty() { + text.push_str( + " NO single algorithm is implicated: the failing nonces do not share one. \ + That is what a race, a barrier, a shared-table fill or the nonce write looks \ + like, not one algorithm function.\n", + ); + } else { + text.push_str(&format!( + " candidates (in every failure, in no passing chain): {}\n", + names(&self.named) + )); + } + text + } +} + +/// Totals for one full `equiv` run. +/// Marker that an error describes the KERNEL'S OUTPUT being wrong rather than a +/// failure to run. +/// +/// It is a tag, not a phrase to be matched by guesswork. The wrapper scripts +/// used to map every error to "the gate could not open the device, nothing was +/// compared", which is what an operator was told on a run where the gate had +/// just caught the exact fault it exists to catch. The exhaustive shapes are +/// small, so a defect that only appears at the production shape reaches the +/// operator through one of these messages and nowhere else. +pub const DETECTED: &str = "[detected] "; + +#[derive(Clone, Debug, Default)] +pub struct EquivReport { + /// Which backend produced this. Free text so the report cannot be mistaken + /// for the other card's. + pub backend: String, + /// The device the backend actually opened. + pub device: String, + pub compared: u64, + pub mismatches: Vec, + /// How many times each of the 16 algorithms was actually executed by the + /// compared corpus, according to the CPU. A zero here means the gate never + /// tested that algorithm, which is a hole in the gate, not a pass. + pub algo_counts: [u64; 16], + /// Sampled chains of nonces the card got RIGHT, per algorithm, counted once + /// per chain. The denominator of the attribution above. + pub passing_algo_chains: [u64; 16], + pub passing_chain_samples: u64, + pub exhaustive_batches: u32, + pub production_batches: u32, + /// What the caller ASKED for, so a pass can be checked against the work that + /// was requested rather than only against the work that happened. + /// + /// `--headers 0` and `--batches 0` each make the exhaustive loop body never + /// run. The device is still opened, the production pass still satisfies + /// `compared > 0` and every algorithm count, and the gate printed PASS with + /// zero bytes compared exhaustively. The byte-for-byte pass is the whole + /// reason this exists, so a flag must not be able to delete it quietly. + pub asked_exhaustive: bool, + pub asked_production: bool, + pub production_nonces: u64, + /// Launches whose full-window hit count was compared against the CPU's. + pub production_count_checks: u32, + /// Production windows whose best-hash reduction was proved to be the true + /// CPU minimum of the window. + pub production_reduction_checks: u32, + pub wall_seconds: f64, +} + +/// Rank thresholds for the production-shape count check. +/// +/// `levels` evenly spaced ranks, plus a few tiny ones so that some launches +/// return a share list short enough to be complete, plus `capacity` itself so +/// exactly one launch fills the list and every byte of it is compared. +pub fn threshold_ranks(window: u64, capacity: u64, levels: u32) -> Vec { + let levels = levels.max(1) as u64; + let mut ranks = vec![1u64, 2, 16, 256, capacity.min(window)]; + for step in 1..=levels { + ranks.push((window.saturating_mul(step) / (levels + 1)).max(1)); + } + ranks.retain(|rank| *rank >= 1 && *rank <= window); + ranks.sort_unstable(); + ranks.dedup(); + ranks +} + +/// Probability that ONE wrong hash anywhere in the window slips past EVERY +/// count threshold. +/// +/// The thresholds cut the window's sorted hashes into bins. A count changes +/// only when the true hash and the corrupted one fall on opposite sides of some +/// threshold, so the check misses exactly when the corrupted value lands in the +/// same bin as the true one. Assuming a corrupted hash is uniform over the +/// 256-bit range and the true hash is at a uniformly random rank, that is the +/// sum of the squared bin widths. +/// +/// The naive "one minus the product over thresholds" is WRONG here: the +/// threshold outcomes are not independent, they are all determined by where the +/// one corrupted value lands. Getting this right matters, because the wrong +/// formula makes 28 thresholds look like p = 1e-4 when the truth is p = 3e-2. +pub fn threshold_miss_probability(window: u64, ranks: &[u64]) -> f64 { + if window == 0 { + return 1.0; + } + let mut edges: Vec = ranks.iter().map(|r| *r as f64 / window as f64).collect(); + edges.push(1.0); + let mut previous = 0.0f64; + let mut sum = 0.0f64; + for edge in edges { + let width = (edge - previous).max(0.0); + sum += width * width; + previous = edge; + } + sum +} + +impl EquivReport { + pub fn passed(&self) -> bool { + self.mismatches.is_empty() + && self.compared > 0 + && self.algo_counts.iter().all(|count| *count > 0) + && self.ran_what_was_asked() + } + + /// Every pass the caller requested actually executed at least once. + /// + /// Without this, `--headers 0` or `--batches 0` removes the exhaustive + /// byte-for-byte comparison and the gate still exits 0, because the + /// production pass alone satisfies every other condition. A green gate that + /// compared nothing is worse than a red one: it is believed. + pub fn ran_what_was_asked(&self) -> bool { + (!self.asked_exhaustive || self.exhaustive_batches > 0) + && (!self.asked_production || self.production_batches > 0) + } + + /// Why `passed()` is false, in the operator's words rather than a bool. + pub fn failure_reason(&self) -> Option { + if !self.mismatches.is_empty() { + return Some(format!( + "{} hashes differ from the CPU", + self.mismatches.len() + )); + } + if self.compared == 0 { + return Some("nothing was compared".to_string()); + } + if let Some(algo) = self.algo_counts.iter().position(|count| *count == 0) { + return Some(format!( + "algorithm {algo} was never exercised, so the gate did not test it" + )); + } + if self.asked_exhaustive && self.exhaustive_batches == 0 { + return Some( + "the exhaustive byte-for-byte pass was requested but ran zero batches; \ + check --headers and --batches" + .to_string(), + ); + } + if self.asked_production && self.production_batches == 0 { + return Some( + "the production-shape pass was requested but ran zero batches; \ + check --prod-batches" + .to_string(), + ); + } + None + } + + /// Name the broken algorithm when the mismatches allow it. See + /// [`AlgoAttribution`] for why both halves of the test are needed. + pub fn attribute(&self) -> AlgoAttribution { + let mut out = AlgoAttribution { + passing: self.passing_algo_chains, + passing_chains: self.passing_chain_samples, + failing_chains: self.mismatches.len() as u64, + ..Default::default() + }; + for mismatch in &self.mismatches { + // Once per chain, not once per round: a chain that runs shabal three + // times is still one failing nonce, and counting rounds would make a + // frequently repeated algorithm look guilty. + let mut seen = [false; 16]; + for algo in &mismatch.algos { + seen[(*algo & 0x0f) as usize] = true; + } + for (index, hit) in seen.iter().enumerate() { + if *hit { + out.failing[index] += 1; + } + } + } + if out.failing_chains == 0 { + return out; + } + for index in 0..16u8 { + if out.failing[index as usize] == out.failing_chains { + out.in_every_failure.push(index); + if out.passing[index as usize] == 0 { + out.named.push(index); + } + } + } + out + } + + pub fn render(&self) -> String { + let mut text = String::new(); + text.push_str(&format!( + " backend / device : {} / {}\n", + if self.backend.is_empty() { + "?" + } else { + &self.backend + }, + if self.device.is_empty() { + "?" + } else { + &self.device + }, + )); + text.push_str(&format!( + " hashes compared byte-for-byte : {}\n \ + exhaustive batches (ENTIRE window dumped and compared) : {}\n \ + production-shape windows : {} ({} nonces, all CPU-hashed)\n \ + production full-window count checks : {} (each reads all {} GPU hashes)\n \ + production best-hash reductions proved minimal : {}\n \ + mismatches : {}\n wall : {:.1}s\n", + self.compared, + self.exhaustive_batches, + self.production_batches, + self.production_nonces, + self.production_count_checks, + if self.production_batches > 0 { + self.production_nonces / self.production_batches as u64 + } else { + 0 + }, + self.production_reduction_checks, + self.mismatches.len(), + self.wall_seconds, + )); + text.push_str(" algorithm coverage (rounds executed, CPU-derived):\n"); + for (index, count) in self.algo_counts.iter().enumerate() { + text.push_str(&format!( + " {:>2} {:<10} {:>12}{}\n", + index, + ALGO_NAMES[index], + count, + if *count == 0 { + " <-- NEVER TESTED" + } else { + "" + } + )); + } + text.push_str(&self.attribute().render()); + for mismatch in self.mismatches.iter().take(20) { + text.push_str(&mismatch.render()); + text.push('\n'); + } + if self.mismatches.len() > 20 { + text.push_str(&format!( + " ... and {} more mismatches (only the first {} are stored)\n", + self.mismatches.len() - 20, + MAX_STORED_MISMATCHES + )); + } + text + } +} + +/// Mismatches kept in full. Past this the run has failed many times over and the +/// rest add nothing but memory; the blame attribution is already saturated. +pub const MAX_STORED_MISMATCHES: usize = 4096; + +pub const ALGO_NAMES: [&str; 16] = [ + "blake", + "bmw", + "groestl", + "jh", + "keccak", + "skein", + "luffa", + "cubehash", + "shavite", + "simd", + "echo", + "hamsi", + "fugue", + "shabal", + "whirlpool", + "sha512", +]; + +/// Entries the pool share list holds, on both backends. +/// +/// `app/src/opencl_gpu/resources.rs` and `x16rs-cuda/src/lib.rs` each define +/// their own; this is the number the gate's exhaustive window is sized from, and +/// `run_equivalence_on` refuses to start if a backend disagrees with it, so the +/// two cannot drift apart silently. +pub const SHARE_LIST_CAPACITY: u64 = 1024; + +/// Launch shapes used by the exhaustive pass. Each one hashes exactly +/// `SHARE_LIST_CAPACITY` nonces so the share list returns the ENTIRE window, +/// while placing those nonces on the card differently: the counting sort in +/// `X16RS_RUN_REPEAT_LOOP` is per work group over `local_size * unit_size` +/// slots, so varying `unit_size` varies the ordering the kernel builds and the +/// contention on the histogram. A single shape would leave that untested. +/// +/// All three hold `local_size` at 256. That is not a preference: the CUDA host +/// fixes the block size at `x16rs_cuda::DEFAULT_LOCAL_SIZE` because +/// `block_miner.cu` declares `__shared__ unsigned int local_nonces[256]` and +/// reduces over a power-of-two tree, so 256 is the only block size that kernel +/// is correct at. Since the OpenCL gate already used 256 throughout, the two +/// backends run the identical three shapes and their reports are comparable. +pub const EXHAUSTIVE_SHAPES: [(u32, u32, u32); 3] = [ + // (work_groups, local_size, unit_size) product must equal SHARE_LIST_CAPACITY + (1, 256, 4), + (2, 256, 2), + (4, 256, 1), +]; + +/// The launch shape the rig actually mines with. Configurable so the gate can +/// be re-pointed when the tuning changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Shape { + pub work_groups: u32, + pub local_size: u32, + pub unit_size: u32, +} + +impl Shape { + pub fn nonces(&self) -> u64 { + self.work_groups as u64 * self.local_size as u64 * self.unit_size as u64 + } +} + +impl std::fmt::Display for Shape { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}x{}x{}", + self.work_groups, self.local_size, self.unit_size + ) + } +} + +/// Whether a device allocated for `allocated` may be LAUNCHED at `wanted`, on a +/// backend that takes its launch shape per call. +/// +/// That is OpenCL: `do_group_block_mining_opencl` receives work_groups, +/// local_size and unit_size on every call, so one allocation serves every +/// smaller shape - which is what lets the auto-tuner measure forty candidates +/// against one set of buffers. Larger is refused rather than clamped: +/// `initialize_opencl` sizes `global_hashes` and `global_order` from the shape +/// it was opened with and the kernel indexes them by the shape it is LAUNCHED +/// with, so a larger launch writes past the buffers, which on a GPU is not a +/// crash but a wrong answer somewhere else. +/// +/// Free-standing and feature-free so the rule can be tested without a card. +pub fn launch_fits_allocation(allocated: Shape, wanted: Shape) -> Result<(), String> { + if wanted.local_size != allocated.local_size + || wanted.work_groups > allocated.work_groups + || wanted.unit_size > allocated.unit_size + { + return Err(format!( + "launch shape {wanted} does not fit a device allocated for {allocated}: this backend \ + may be launched at any SMALLER shape with the same local_size, never a larger one" + )); + } + Ok(()) +} + +/// The same question on a backend that bakes `unit_size` into the allocation AND +/// hands it to the kernel from there. +/// +/// That is CUDA: `mine_batch_inner` passes `miner.unit_size`, so a miner built +/// at 64 asked for 128 does not fail - it runs 64 nonces per thread, returns +/// correct hashes for 64, and the caller labels the result 128. Nothing +/// downstream could catch that: the hashes are right, the equivalence proof +/// passes, and only the TIME is attributed to the wrong shape. So unit_size must +/// match exactly. Work groups are clamped per launch by +/// `mine_block_batch_shares`, so fewer is a real launch of fewer and more is +/// refused for the same reason a mislabelled unit_size is. +pub fn launch_fits_bound_allocation(allocated: Shape, wanted: Shape) -> Result<(), String> { + if wanted.unit_size != allocated.unit_size || wanted.local_size != allocated.local_size { + return Err(format!( + "launch shape {wanted} cannot run on a device built for {allocated}: unit_size and \ + local_size are fixed at allocation and passed to the kernel from it, so this launch \ + would run unit_size {} and be reported as {}", + allocated.unit_size, wanted.unit_size + )); + } + if wanted.work_groups > allocated.work_groups { + return Err(format!( + "launch shape {wanted} asks for more work groups than were allocated ({allocated})" + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Everything below needs a real device. +// --------------------------------------------------------------------------- + +/// One opened device, and the launch shapes it can be asked for. +/// +/// Three operations, and they are the only thing a backend has to supply. Every +/// piece of judgement - what to hash, what to compare it with, what counts as a +/// pass, which algorithm to blame - is above this line and is compiled once. +/// +/// The launch shape is a PARAMETER of each operation rather than a property of +/// the device, because the two are not the same thing and the difference is the +/// whole reason the auto-tuner can share this trait. A device is opened with +/// buffers sized for one shape ([`GateDevice::allocated_shape`]); an OpenCL +/// device can then be launched at any smaller shape against those same buffers, +/// which is how the tuner measures forty candidates without recompiling a kernel +/// forty times, while a CUDA device cannot, because `unit_size` is baked into +/// its allocation AND passed to the kernel from the miner. Each backend says +/// which it is through [`GateBackend::device_is_bound_to_its_shape`], and each +/// device refuses a shape it cannot honestly launch instead of quietly running a +/// different one. +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub trait GateDevice { + /// The shape this device's buffers were sized for. + fn allocated_shape(&self) -> Shape; + + /// True when this already-open device can launch `shape` honestly: not + /// beyond its allocation, and not by quietly running a different shape. + /// + /// The auto-tuner asks before every candidate. Answering it wrongly in the + /// permissive direction is the failure that would not announce itself: a + /// CUDA miner asked for a `unit_size` it was not built with runs the one it + /// WAS built with, returns correct hashes for that shape, passes every + /// equivalence proof, and hands back a time the tuner would attribute to the + /// shape it asked for. Each implementation answers from the same rule its + /// launch path enforces, so the two cannot drift. + fn can_launch(&self, shape: Shape) -> bool; + + /// Launch at `shape` from `nonce_start` with `share_target`, and return + /// (total hits the kernel counted, the entries that fit in the share list). + /// + /// The total is the KERNEL's own count, including hits it could not store. + /// That is what makes the production pass possible: it is a statement about + /// every hash in the window, not about the ones that fit down the pipe. + fn count_and_shares( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + share_target: &[u8; 32], + ) -> Result<(u64, Vec<(u32, [u8; 32])>), String>; + + /// The best-hash reduction: one nonce and hash for the whole window. This is + /// a different code path from the share list and it is the ONLY one solo + /// mining reads, so the gate proves it separately. + fn best( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + ) -> Result<(u32, [u8; 32]), String>; + + /// Every hash the card produced for a window, in NONCE ORDER. + /// + /// Provided, not per backend: this is the trick the whole exhaustive mode + /// rests on (weakest possible target, so every nonce qualifies and the share + /// list becomes a full dump), and having it in one place is what stops the + /// two backends proving subtly different things. Errors rather than + /// truncates if the card did not return the whole window, because a partial + /// dump would silently shrink the gate to a sample. + fn dump_window( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + ) -> Result, String> { + let count = shape.nonces(); + let (hits, shares) = + self.count_and_shares(shape, height, intro, nonce_start, &[0xffu8; 32])?; + if hits != count { + return Err(format!( + "{DETECTED}kernel counted {hits} hits for a {count}-nonce window; with target 0xff..ff every \ + nonce must qualify, so the kernel did not hash the window it was asked to" + )); + } + if shares.len() as u64 != count { + return Err(format!( + "{DETECTED}share list returned {} of {count} nonces", + shares.len() + )); + } + let mut window: Vec> = vec![None; count as usize]; + for (nonce, hash) in shares { + let offset = nonce.wrapping_sub(nonce_start) as u64; + if offset >= count { + return Err(format!( + "{DETECTED}kernel returned nonce {nonce}, outside the window [{nonce_start}, {})", + nonce_start.wrapping_add(count as u32) + )); + } + if window[offset as usize].is_some() { + return Err(format!("{DETECTED}kernel returned nonce {nonce} twice")); + } + window[offset as usize] = Some(hash); + } + window + .into_iter() + .enumerate() + .map(|(i, cell)| { + cell.ok_or_else(|| { + format!( + "kernel never returned nonce {}", + nonce_start as u64 + i as u64 + ) + }) + }) + .collect() + } +} + +/// A way to open devices. One per GPU API. +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub trait GateBackend { + type Device: GateDevice; + + /// What the report calls this backend. + fn name(&self) -> &'static str; + + /// The device under test, named well enough to identify it in a log. + fn describe(&self) -> String; + + /// Entries this backend's share list holds. Checked against + /// [`SHARE_LIST_CAPACITY`] before anything runs. + fn share_capacity(&self) -> u64; + + /// Reject a shape this backend cannot launch, before a device is opened and + /// before the CPU spends minutes hashing an oracle for it. + fn check_shape(&self, shape: Shape) -> Result<(), String>; + + /// Open a device with buffers sized for `shape`. + fn open(&self, shape: Shape) -> Result; + + /// True when a device opened at one shape can only ever launch THAT shape, + /// so measuring a different one means opening a new device. + /// + /// False for OpenCL: `do_group_block_mining_opencl` takes work_groups, + /// local_size and unit_size per launch, so one allocation sized at the top + /// of a grid serves every point under it. True for CUDA: `cuda_mine_batch` + /// reads `unit_size` from the miner it was built with and hands THAT to the + /// kernel, so a CudaMiner allocated at unit_size 64 cannot be asked for 128 + /// at all - it would silently run 64 again and report the wrong shape's + /// number. + /// + /// The auto-tuner asks this and nothing else about the difference. Getting + /// it wrong in the safe direction costs a device open per candidate; getting + /// it wrong the other way would have every CUDA candidate measure the same + /// unit_size and the tuner would then write a shape it never ran. + fn device_is_bound_to_its_shape(&self) -> bool; +} + +/// Open one device with buffers sized for `unit_size`. +#[cfg(feature = "ocl")] +pub fn open_device( + opencl_dir: &str, + platform: u32, + device: &str, + shape: Shape, +) -> Result { + let scan = crate::opencl_diag::scan_opencl(); + let dir = opencl_dir.to_string(); + let devices = device.to_string(); + let mut resources = crate::opencl_gpu::initialize_opencl( + false, + &dir, + &platform, + &devices, + &shape.work_groups, + &shape.local_size, + &shape.unit_size, + Some(&scan), + true, + ); + if resources.is_empty() { + return Err(format!( + "no usable OpenCL device (platform {platform}, device_ids '{device}', dir '{opencl_dir}')" + )); + } + if resources.len() != 1 { + return Err(format!( + "{} devices selected; the gate measures one device at a time", + resources.len() + )); + } + Ok(resources.remove(0)) +} + +// --------------------------------------------------------------------------- +// Backend: OpenCL +// --------------------------------------------------------------------------- + +#[cfg(feature = "ocl")] +use crate::opencl_gpu::{OpenCLResources, block::do_group_block_mining_opencl_shares}; + +/// The OpenCL device under test, opened at one shape. +#[cfg(feature = "ocl")] +pub struct OclGateDevice { + resources: OpenCLResources, + shape: Shape, +} + +#[cfg(feature = "ocl")] +impl OclGateDevice { + /// A launch this device's buffers can actually hold. See + /// [`launch_fits_allocation`], which is where the rule lives so it can be + /// tested without a card. + fn check_launch(&self, shape: Shape) -> Result<(), String> { + launch_fits_allocation(self.shape, shape) + } +} + +#[cfg(feature = "ocl")] +impl GateDevice for OclGateDevice { + fn allocated_shape(&self) -> Shape { + self.shape + } + + fn can_launch(&self, shape: Shape) -> bool { + self.check_launch(shape).is_ok() + } + + fn count_and_shares( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + share_target: &[u8; 32], + ) -> Result<(u64, Vec<(u32, [u8; 32])>), String> { + self.check_launch(shape)?; + let out = do_group_block_mining_opencl_shares( + &self.resources, + height, + intro.to_vec(), + nonce_start, + shape.work_groups, + shape.local_size, + shape.unit_size, + Some(share_target), + ) + .map_err(|e| e.display())?; + Ok((out.share_hits, out.shares)) + } + + fn best( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + ) -> Result<(u32, [u8; 32]), String> { + self.check_launch(shape)?; + crate::opencl_gpu::block::do_group_block_mining_opencl( + &self.resources, + height, + intro.to_vec(), + nonce_start, + shape.work_groups, + shape.local_size, + shape.unit_size, + ) + .map_err(|e| e.display()) + } +} + +/// Which OpenCL device to open, and from which kernel tree. +/// +/// The kernel directory is part of the backend because OpenCL compiles at +/// RUNTIME, which is what lets `scripts/x16rs_gate_trees.py` prove the gate can +/// fail: point it at a tree with a deliberate defect and the same binary catches +/// it. CUDA has no equivalent knob at this level; see [`CudaBackend`]. +#[cfg(feature = "ocl")] +pub struct OclBackend { + pub opencl_dir: String, + pub platform: u32, + pub device: String, +} + +#[cfg(feature = "ocl")] +impl GateBackend for OclBackend { + type Device = OclGateDevice; + + fn name(&self) -> &'static str { + "opencl" + } + + fn describe(&self) -> String { + format!( + "platform {}, device_ids '{}', kernels from {}", + self.platform, self.device, self.opencl_dir + ) + } + + fn share_capacity(&self) -> u64 { + crate::opencl_gpu::SHARE_LIST_CAPACITY as u64 + } + + fn check_shape(&self, shape: Shape) -> Result<(), String> { + if shape.work_groups == 0 || shape.local_size == 0 || shape.unit_size == 0 { + return Err(format!("launch shape {shape} has a zero dimension")); + } + Ok(()) + } + + fn open(&self, shape: Shape) -> Result { + Ok(OclGateDevice { + resources: open_device(&self.opencl_dir, self.platform, &self.device, shape)?, + shape, + }) + } + + fn device_is_bound_to_its_shape(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Backend: CUDA +// --------------------------------------------------------------------------- + +/// The CUDA device under test, opened at one shape. +/// +/// `x16rs_cuda::CudaMiner` owns its device allocations and sizes them from +/// (work_groups, 256, unit_size) at construction, exactly like the OpenCL +/// resources, so the gate opens one per shape and drops it after. +#[cfg(feature = "cuda")] +pub struct CudaGateDevice { + miner: x16rs_cuda::CudaMiner, + shape: Shape, +} + +#[cfg(feature = "cuda")] +impl CudaGateDevice { + /// A launch this miner can really perform. See + /// [`launch_fits_bound_allocation`], which is where the rule lives so it + /// can be tested without a card. + fn check_launch(&self, shape: Shape) -> Result<(), String> { + launch_fits_bound_allocation(self.shape, shape) + } +} + +#[cfg(feature = "cuda")] +impl GateDevice for CudaGateDevice { + fn allocated_shape(&self) -> Shape { + self.shape + } + + fn can_launch(&self, shape: Shape) -> bool { + self.check_launch(shape).is_ok() + } + + fn count_and_shares( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + share_target: &[u8; 32], + ) -> Result<(u64, Vec<(u32, [u8; 32])>), String> { + self.check_launch(shape)?; + let out = self + .miner + .mine_block_batch_shares( + height, + intro, + nonce_start, + shape.work_groups, + Some(share_target), + ) + .map_err(|e| e.to_string())?; + Ok((out.share_hits, out.shares)) + } + + fn best( + &self, + shape: Shape, + height: u64, + intro: &[u8], + nonce_start: u32, + ) -> Result<(u32, [u8; 32]), String> { + self.check_launch(shape)?; + self.miner + .mine_block_batch(height, intro, nonce_start, shape.work_groups) + .map_err(|e| e.to_string()) + } +} + +/// True when this binary actually contains compiled CUDA kernels. +/// +/// The `cuda` feature only adds the x16rs-cuda crate. Whether that crate holds +/// kernels is decided by its build script finding nvcc, which sets +/// `cfg(cuda_available)`; without it the crate still compiles and every device +/// call returns `NotCompiled`. Callers must check this before claiming a CUDA +/// run proved anything, and the gate binary exits non-zero when it is false. +#[cfg(feature = "cuda")] +pub fn cuda_kernels_available() -> bool { + x16rs_cuda::CudaMiner::is_available() +} + +/// Which CUDA device to open. +/// +/// There is no kernel-directory knob here and there cannot be one at runtime: +/// `x16rs-cuda/build.rs` compiles `block_miner.cu` with nvcc into the static +/// library at BUILD time, so a CUDA kernel change means a rebuild. Fault +/// injection is still available, and against the very same defects the OpenCL +/// gate was proved with, because `block_miner.cu` includes `util.cl`, +/// `sha3_256.cl` and `x16rs.cl` straight out of `x16rs/opencl`: set +/// `X16RS_CUDA_KERNEL_DIR` to a tree built by `scripts/x16rs_gate_trees.py` and +/// rebuild, and this gate is running the broken algorithm on the card. +#[cfg(feature = "cuda")] +pub struct CudaBackend { + pub device_index: i32, +} + +#[cfg(feature = "cuda")] +impl GateBackend for CudaBackend { + type Device = CudaGateDevice; + + fn name(&self) -> &'static str { + "cuda" + } + + fn describe(&self) -> String { + match x16rs_cuda::CudaMiner::list_devices() { + Ok(devices) => match devices.iter().find(|d| d.index == self.device_index) { + Some(d) => format!( + "device #{} {} (SM {}.{}, {} MPs)", + d.index, d.name, d.compute_major, d.compute_minor, d.multiprocessor_count + ), + None => format!( + "device #{} (not present; {} device(s) visible)", + self.device_index, + devices.len() + ), + }, + Err(e) => format!("device #{} (enumeration failed: {e})", self.device_index), + } + } + + fn share_capacity(&self) -> u64 { + x16rs_cuda::SHARE_LIST_CAPACITY as u64 + } + + fn check_shape(&self, shape: Shape) -> Result<(), String> { + if shape.work_groups == 0 || shape.local_size == 0 || shape.unit_size == 0 { + return Err(format!("launch shape {shape} has a zero dimension")); + } + // Not a limitation of the gate. `x16rs_cuda_main` declares + // `__shared__ unsigned int local_nonces[256]` and reduces over a + // power-of-two tree across the block, so any other block size either + // overruns that array or reduces the wrong pairs. The host fixes it at + // DEFAULT_LOCAL_SIZE for the same reason and refuses a device whose + // maxThreadsPerBlock is lower. Say so here rather than let the operator + // read a wrong-hash report caused by their own --local-size. + if shape.local_size != x16rs_cuda::DEFAULT_LOCAL_SIZE { + return Err(format!( + "CUDA runs at local_size = {} only ({shape} was asked for): block_miner.cu's \ + shared local_nonces[{}] and its power-of-two tree reduction are correct at that \ + block size and no other", + x16rs_cuda::DEFAULT_LOCAL_SIZE, + x16rs_cuda::DEFAULT_LOCAL_SIZE + )); + } + Ok(()) + } + + fn open(&self, shape: Shape) -> Result { + self.check_shape(shape)?; + let miner = + x16rs_cuda::CudaMiner::new(self.device_index, shape.work_groups, shape.unit_size) + .map_err(|e| { + format!("opening CUDA device #{} at {shape}: {e}", self.device_index) + })?; + Ok(CudaGateDevice { miner, shape }) + } + + fn device_is_bound_to_its_shape(&self) -> bool { + true + } +} + +/// Everything `equiv` needs that is not the device. +#[derive(Clone, Copy, Debug)] +pub struct EquivParams { + /// Corpus headers to hash. + pub headers: u32, + /// Exhaustive windows per (shape, height, header). + pub batches: u32, + /// The shape the rig actually mines with, or a zero `prod_batches` to skip. + pub prod_shape: Shape, + pub prod_batches: u32, + /// Rank thresholds used by the production count check. + pub prod_thresholds: u32, + /// CPU threads for the oracle. + pub threads: usize, +} + +/// Full byte-equivalence run, on whichever backend is handed in. +/// +/// `headers` corpus entries x `batches` exhaustive windows x every shape in +/// `EXHAUSTIVE_SHAPES` x every repeat in `GATE_HEIGHTS`, plus `prod_batches` +/// runs at the production shape. +/// +/// This function is the gate. Both backends run this exact code over this exact +/// corpus against this exact oracle, so a defect either card has is judged by +/// one standard, and a change to the standard cannot reach one backend and miss +/// the other. +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub fn run_equivalence_on( + backend: &B, + params: EquivParams, +) -> Result { + let EquivParams { + headers, + batches, + prod_shape, + prod_batches, + prod_thresholds, + threads, + } = params; + let started = Instant::now(); + let mut report = EquivReport { + backend: backend.name().to_string(), + device: backend.describe(), + asked_exhaustive: headers > 0 && batches > 0, + asked_production: prod_batches > 0, + ..Default::default() + }; + + // The exhaustive mode is only exhaustive if the share list really is as big + // as this module thinks. A backend whose capacity moved would silently turn + // every "ENTIRE window" claim into a sample, so refuse before hashing + // anything. + let capacity = backend.share_capacity(); + if capacity != SHARE_LIST_CAPACITY { + return Err(format!( + "{} reports a share list capacity of {capacity}, the gate is built for \ + {SHARE_LIST_CAPACITY}; the exhaustive shapes would no longer dump whole windows", + backend.name() + )); + } + + // Exhaustive pass. One device open per shape, because the GPU buffers are + // sized from unit_size at init. + for (work_groups, local_size, unit_size) in EXHAUSTIVE_SHAPES { + let shape = Shape { + work_groups, + local_size, + unit_size, + }; + if shape.nonces() != capacity { + return Err(format!( + "exhaustive shape {shape} hashes {} nonces but the share list holds {capacity}; \ + an exhaustive dump needs them equal", + shape.nonces() + )); + } + backend.check_shape(shape)?; + let device = backend.open(shape)?; + let count = shape.nonces() as u32; + for height in GATE_HEIGHTS { + let repeat = x16rs::block_hash_repeat(height); + for header_index in 0..headers { + let intro = corpus_header(header_index); + for batch in 0..batches { + // A distinct nonce base per (shape, height, header, batch) + // so the gate never re-tests the same hashes twice. + let nonce_start = nonce_base(unit_size, height, header_index, batch); + let gpu = device.dump_window(shape, height, &intro, nonce_start)?; + let cpu = cpu_hash_window(height, &intro, nonce_start, count, threads); + report.exhaustive_batches += 1; + compare_window( + &mut report, + height, + repeat, + header_index, + nonce_start, + &intro, + &gpu, + &cpu, + ); + } + } + } + drop(device); + } + + // Production-shape pass. + // + // A production launch hashes 589 824 nonces and the share list holds 1024, + // so the whole window cannot be dumped. Sampling 1024 of them would leave + // 99.83% of the shape the miner actually runs unchecked, which is not a + // gate. Instead this pass uses the share COUNTER, which the kernel + // increments for every hash in the window: + // + // * the CPU hashes the entire window and sorts it; + // * for each of several rank thresholds k, the target is set to the k-th + // smallest CPU hash, so exactly k of the 589 824 must qualify; + // * the kernel is run and `share_hits` must equal k EXACTLY. + // + // Every one of those launches reads all 589 824 GPU hashes. A single + // corrupted hash lands on the wrong side of a threshold at quantile q with + // probability ~2q(1-q); spread the thresholds and one wrong hash anywhere + // in the window is caught with high probability. On top of that, the run + // whose threshold yields exactly SHARE_LIST_CAPACITY hits returns those + // 1024 hashes in full, and every byte of them is compared. + if prod_batches > 0 && prod_shape.nonces() > 0 { + backend.check_shape(prod_shape)?; + let device = backend.open(prod_shape)?; + let height = REPEAT16_HEIGHT; + let repeat = x16rs::block_hash_repeat(height); + let window = prod_shape.nonces(); + if window > u32::MAX as u64 { + return Err("production window exceeds the 32-bit nonce space".to_string()); + } + let ranks = threshold_ranks(window, capacity, prod_thresholds); + for batch in 0..prod_batches { + let header_index = batch % headers.max(1); + let intro = corpus_header(header_index); + let nonce_start = 0x4000_0000u32.wrapping_add(batch.wrapping_mul(window as u32)); + + // The oracle for the WHOLE window, not a sample. + let cpu = cpu_hash_window(height, &intro, nonce_start, window as u32, threads); + let mut sorted = cpu.clone(); + sorted.sort_unstable(); + + report.production_batches += 1; + report.production_nonces += window; + + for rank in ranks.iter().copied() { + let target = sorted[(rank - 1) as usize]; + let (share_hits, shares) = + device.count_and_shares(prod_shape, height, &intro, nonce_start, &target)?; + report.production_count_checks += 1; + if share_hits != rank { + return Err(format!( + "{DETECTED}production shape, header {header_index}, nonce base {nonce_start}: \ + the CPU says exactly {rank} of the {window} hashes are <= {}, the kernel counted {share_hits}. \ + The kernel's hashes differ from the CPU's somewhere in the window.", + hex::encode(target), + )); + } + // Whatever did come back must be byte-exact and in-window. + for (nonce, hash) in &shares { + let offset = nonce.wrapping_sub(nonce_start) as u64; + if offset >= window { + return Err(format!( + "production shape returned nonce {nonce}, outside the window" + )); + } + record( + &mut report, + height, + repeat, + header_index, + *nonce, + &intro, + hash, + &cpu[offset as usize], + ); + } + } + + // The best-hash reduction is a separate code path from the share + // list, and it is the ONLY path solo mining reads. It must return + // the true minimum of the window, which the sorted oracle knows. + let (best_nonce, best_hash) = device.best(prod_shape, height, &intro, nonce_start)?; + if best_hash != sorted[0] { + return Err(format!( + "{DETECTED}production shape best-hash reduction returned {} for nonce {best_nonce}; \ + the CPU minimum over the window is {}", + hex::encode(best_hash), + hex::encode(sorted[0]) + )); + } + let best_offset = best_nonce.wrapping_sub(nonce_start) as u64; + if best_offset >= window || cpu[best_offset as usize] != best_hash { + return Err(format!( + "production shape best nonce {best_nonce} does not carry its own hash" + )); + } + report.production_reduction_checks += 1; + } + drop(device); + } + + report.wall_seconds = started.elapsed().as_secs_f64(); + Ok(report) +} + +/// Byte-equivalence run on an OpenCL device. +#[cfg(feature = "ocl")] +#[allow(clippy::too_many_arguments)] +pub fn run_equivalence( + opencl_dir: &str, + platform: u32, + device: &str, + headers: u32, + batches: u32, + prod_shape: Shape, + prod_batches: u32, + prod_thresholds: u32, + threads: usize, +) -> Result { + run_equivalence_on( + &OclBackend { + opencl_dir: opencl_dir.to_string(), + platform, + device: device.to_string(), + }, + EquivParams { + headers, + batches, + prod_shape, + prod_batches, + prod_thresholds, + threads, + }, + ) +} + +/// Byte-equivalence run on a CUDA device. +/// +/// Same corpus, same oracle, same comparison, same report as the OpenCL entry +/// point above: the only difference between them is which three device calls +/// [`run_equivalence_on`] ends up making. +#[cfg(feature = "cuda")] +pub fn run_equivalence_cuda(device_index: i32, params: EquivParams) -> Result { + run_equivalence_on(&CudaBackend { device_index }, params) +} + +/// Nonce base that keeps every (shape, height, header, batch) window disjoint. +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn nonce_base(unit_size: u32, height: u64, header_index: u32, batch: u32) -> u32 { + let height_slot = GATE_HEIGHTS.iter().position(|h| *h == height).unwrap_or(0) as u32; + 0x0100_0000u32 + .wrapping_mul(unit_size) + .wrapping_add(0x0010_0000u32.wrapping_mul(height_slot)) + .wrapping_add(0x0000_8000u32.wrapping_mul(header_index)) + .wrapping_add(0x0000_0400u32.wrapping_mul(batch)) +} + +#[cfg(any(feature = "ocl", feature = "cuda"))] +#[allow(clippy::too_many_arguments)] +fn compare_window( + report: &mut EquivReport, + height: u64, + repeat: i32, + header_index: u32, + nonce_start: u32, + intro: &[u8], + gpu: &[[u8; 32]], + cpu: &[[u8; 32]], +) { + for (i, (g, c)) in gpu.iter().zip(cpu.iter()).enumerate() { + let nonce = nonce_start.wrapping_add(i as u32); + record(report, height, repeat, header_index, nonce, intro, g, c); + } +} + +/// How often a compared nonce's algorithm chain is derived. +/// +/// Deriving it costs `repeat` extra CPU hashes, which at 1-in-1 would double the +/// oracle. 1-in-64 still gives hundreds of samples of every algorithm on a +/// default run, which is all the coverage count and the attribution's +/// passing-chain half need. +#[cfg(any(feature = "ocl", feature = "cuda"))] +const ALGO_SAMPLE_EVERY: u64 = 64; + +#[cfg(any(feature = "ocl", feature = "cuda"))] +#[allow(clippy::too_many_arguments)] +fn record( + report: &mut EquivReport, + height: u64, + repeat: i32, + header_index: u32, + nonce: u32, + intro: &[u8], + gpu: &[u8; 32], + cpu: &[u8; 32], +) { + report.compared += 1; + // Algorithm coverage is sampled, not counted for every nonce: deriving the + // per-round algorithm costs `repeat` extra CPU hashes, which would double + // the oracle's cost for a number that converges in a few hundred samples. + if report.compared % ALGO_SAMPLE_EVERY == 0 { + let chain = algo_sequence(height, intro, nonce); + let mut ran = [false; 16]; + for algo in &chain { + report.algo_counts[(*algo & 0x0f) as usize] += 1; + ran[(*algo & 0x0f) as usize] = true; + } + // The passing half of the blame test. Counted once per chain, and only + // for nonces the card got RIGHT: an algorithm that appears here cannot + // be the broken one, because running it produced a correct hash. + if gpu == cpu { + report.passing_chain_samples += 1; + for (index, hit) in ran.iter().enumerate() { + if *hit { + report.passing_algo_chains[index] += 1; + } + } + } + } + if gpu != cpu { + if report.mismatches.len() < MAX_STORED_MISMATCHES { + report.mismatches.push(Mismatch { + height, + repeat, + header_index, + nonce, + gpu: *gpu, + cpu: *cpu, + algos: algo_sequence(height, intro, nonce), + }); + } + } +} + +// --------------------------------------------------------------------------- +// Fixed-work baseline +// --------------------------------------------------------------------------- + +/// Middle-80% spread of an ascending sample, as a percentage of `centre`. +/// Robust to the one-in-a-dozen stalled run that peak-to-peak cannot survive. +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub fn spread_p10_p90(sorted: &[f64], centre: f64) -> f64 { + if sorted.is_empty() || centre <= 0.0 { + return 0.0; + } + let index = |q: f64| -> usize { + let raw = (q * (sorted.len() as f64 - 1.0)).round() as usize; + raw.min(sorted.len() - 1) + }; + (sorted[index(0.9)] - sorted[index(0.1)]) / centre * 100.0 +} + +/// One timed run over the fixed corpus. +#[cfg(any(feature = "ocl", feature = "cuda"))] +#[derive(Clone, Debug)] +pub struct BaselineRun { + pub seconds: f64, + pub nonces: u64, + pub hashrate: f64, +} + +#[cfg(any(feature = "ocl", feature = "cuda"))] +#[derive(Clone, Debug)] +pub struct BaselineReport { + pub shape: Shape, + pub height: u64, + pub repeat: i32, + pub batches_per_run: u32, + pub nonces_per_run: u64, + pub nonce_start: u32, + pub header_indices: Vec, + pub runs: Vec, + pub cpu_spot_checks: u32, +} + +#[cfg(any(feature = "ocl", feature = "cuda"))] +impl BaselineReport { + fn sorted_rates(&self) -> Vec { + let mut rates: Vec = self.runs.iter().map(|r| r.hashrate).collect(); + rates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + rates + } + pub fn median(&self) -> f64 { + let rates = self.sorted_rates(); + if rates.is_empty() { + return 0.0; + } + let mid = rates.len() / 2; + if rates.len() % 2 == 0 { + (rates[mid - 1] + rates[mid]) / 2.0 + } else { + rates[mid] + } + } + pub fn min(&self) -> f64 { + self.sorted_rates().first().copied().unwrap_or(0.0) + } + pub fn max(&self) -> f64 { + self.sorted_rates().last().copied().unwrap_or(0.0) + } + /// Peak-to-peak spread as a percentage of the median. THIS is the noise + /// floor: a later change that moves the median by less than this has not + /// been shown to do anything. + pub fn spread_pct(&self) -> f64 { + let median = self.median(); + if median <= 0.0 { + return 0.0; + } + (self.max() - self.min()) / median * 100.0 + } + + pub fn render(&self) -> String { + let mut text = format!( + " launch shape : work_groups={} local_size={} unit_size={} ({} nonces/batch)\n \ + height : {} (x16rs repeat = {})\n \ + fixed corpus : {} batches/run, nonce range [{}, {}), headers {:?}\n \ + identical work : every run hashes the SAME {} nonces of the SAME headers\n \ + CPU spot checks : {} best-nonce hashes re-hashed with x16rs::block_hash, byte-equal\n \ + runs : {}\n", + self.shape.work_groups, + self.shape.local_size, + self.shape.unit_size, + self.shape.nonces(), + self.height, + self.repeat, + self.batches_per_run, + self.nonce_start, + self.nonce_start as u64 + self.nonces_per_run, + self.header_indices, + self.nonces_per_run, + self.cpu_spot_checks, + self.runs.len(), + ); + for (i, run) in self.runs.iter().enumerate() { + text.push_str(&format!( + " run {:>2} : {:>8.3}s {:>10}\n", + i + 1, + run.seconds, + crate::bench_mainnet_repeat16::fmt_rate(run.hashrate) + )); + } + text.push_str(&format!( + " median : {}\n min / max : {} / {}\n \ + within-process : p10-p90 {:.2}%, peak-to-peak {:.2}%\n \ + WARNING : the within-process figure is NOT the bar for comparing two builds.\n \ + This card settles into one of several clock/power states for the life of a\n \ + process, and separate invocations of THIS command on identical work have\n \ + disagreed by ~{:.1}% on this rig. To compare two OPENCL kernel trees, use\n \ + `x16rs_gate ab`, which alternates them inside one process and resolves ~0.3%.\n \ + CUDA kernels are compiled into the binary by nvcc, so no in-process A/B\n \ + exists for them: two CUDA builds can only be compared across processes, and\n \ + a difference under the ~{:.1}% between-process spread is not a result.\n", + crate::bench_mainnet_repeat16::fmt_rate(self.median()), + crate::bench_mainnet_repeat16::fmt_rate(self.min()), + crate::bench_mainnet_repeat16::fmt_rate(self.max()), + spread_p10_p90(&self.sorted_rates(), self.median()), + self.spread_pct(), + BETWEEN_PROCESS_SPREAD_PCT, + BETWEEN_PROCESS_SPREAD_PCT, + )); + text + } +} + +/// Fixed-work baseline: hash an identical, deterministic nonce range every run +/// and time it. +/// +/// The corpus is fixed on purpose. x16rs picks a different algorithm chain for +/// every nonce, and the algorithms differ in cost by more than an order of +/// magnitude, so a run-for-N-seconds benchmark compares different work each +/// time and its variance is dominated by which hashes it happened to reach. +#[cfg(any(feature = "ocl", feature = "cuda"))] +pub fn run_baseline_on( + backend: &B, + shape: Shape, + height: u64, + batches_per_run: u32, + runs: u32, + warmup_batches: u32, + headers: u32, +) -> Result { + backend.check_shape(shape)?; + let device = backend.open(shape)?; + let per_batch = shape.nonces(); + let nonce_start = 0x1000_0000u32; + let headers = headers.max(1); + let header_indices: Vec = (0..batches_per_run).map(|b| b % headers).collect(); + let intros: Vec> = (0..headers).map(corpus_header).collect(); + + // Warm-up: clocks, JIT, caches. Outside the timed region and outside the + // corpus, so the measured work is unchanged by how long the warm-up ran. + for w in 0..warmup_batches { + let start = 0xF000_0000u32.wrapping_add(w.wrapping_mul(per_batch as u32)); + device + .best(shape, height, &intros[0], start) + .map_err(|e| format!("warm-up batch {}: {e}", w + 1))?; + } + + let mut out_runs = Vec::with_capacity(runs as usize); + let mut cpu_spot_checks = 0u32; + for run in 0..runs { + let started = Instant::now(); + let mut results: Vec<(u32, [u8; 32], u32)> = Vec::with_capacity(batches_per_run as usize); + for batch in 0..batches_per_run { + let header_index = header_indices[batch as usize]; + let start = nonce_start.wrapping_add(batch.wrapping_mul(per_batch as u32)); + let (nonce, hash) = device + .best(shape, height, &intros[header_index as usize], start) + .map_err(|e| format!("run {} batch {}: {e}", run + 1, batch + 1))?; + results.push((nonce, hash, header_index)); + } + let seconds = started.elapsed().as_secs_f64(); + if !seconds.is_finite() || seconds <= 0.0 { + return Err("non-positive run duration".to_string()); + } + + // Correctness is checked OUTSIDE the timed region so the check cannot + // change the number. Every batch's best nonce is re-hashed on the CPU: + // a timing run that measured a broken kernel is worthless. + for (nonce, hash, header_index) in &results { + let expect = cpu_hash(height, &intros[*header_index as usize], *nonce); + if expect != *hash { + return Err(format!( + "run {} produced a wrong hash at nonce {}: gpu={} cpu={}", + run + 1, + nonce, + hex::encode(hash), + hex::encode(expect) + )); + } + cpu_spot_checks += 1; + } + + let nonces = per_batch.saturating_mul(batches_per_run as u64); + out_runs.push(BaselineRun { + seconds, + nonces, + hashrate: nonces as f64 / seconds, + }); + } + + Ok(BaselineReport { + shape, + height, + repeat: x16rs::block_hash_repeat(height), + batches_per_run, + nonces_per_run: per_batch.saturating_mul(batches_per_run as u64), + nonce_start, + header_indices: (0..headers).collect(), + runs: out_runs, + cpu_spot_checks, + }) +} + +/// Fixed-work baseline on an OpenCL device. +#[cfg(feature = "ocl")] +#[allow(clippy::too_many_arguments)] +pub fn run_baseline( + opencl_dir: &str, + platform: u32, + device: &str, + shape: Shape, + height: u64, + batches_per_run: u32, + runs: u32, + warmup_batches: u32, + headers: u32, +) -> Result { + run_baseline_on( + &OclBackend { + opencl_dir: opencl_dir.to_string(), + platform, + device: device.to_string(), + }, + shape, + height, + batches_per_run, + runs, + warmup_batches, + headers, + ) +} + +/// Fixed-work baseline on a CUDA device. +#[cfg(feature = "cuda")] +pub fn run_baseline_cuda( + device_index: i32, + shape: Shape, + height: u64, + batches_per_run: u32, + runs: u32, + warmup_batches: u32, + headers: u32, +) -> Result { + run_baseline_on( + &CudaBackend { device_index }, + shape, + height, + batches_per_run, + runs, + warmup_batches, + headers, + ) +} + +// --------------------------------------------------------------------------- +// Paired A/B +// +// OpenCL only, and not by omission. `ab` alternates two KERNEL TREES inside one +// process, which works because OpenCL compiles its kernels at runtime from a +// directory the caller names. `x16rs-cuda/build.rs` compiles block_miner.cu with +// nvcc into the static library, so a CUDA process contains exactly one kernel +// build and cannot alternate. Comparing two CUDA kernels means two binaries, and +// the paired-within-one-process trick that resolves 0.3% on this rig is simply +// not available there; `baseline`'s ~2.6% between-process figure is. +// --------------------------------------------------------------------------- + +/// One A-then-B pair, timed back to back on the same card in the same process. +#[cfg(feature = "ocl")] +#[derive(Clone, Debug)] +pub struct AbPair { + pub a_seconds: f64, + pub b_seconds: f64, + /// B's hashrate divided by A's. > 1 means B is faster. + pub ratio: f64, +} + +#[cfg(feature = "ocl")] +#[derive(Clone, Debug)] +pub struct AbReport { + pub dir_a: String, + pub dir_b: String, + pub shape: Shape, + pub height: u64, + pub batches_per_leg: u32, + pub nonces_per_leg: u64, + pub pairs: Vec, + pub cpu_checks: u32, + /// True when both legs produced identical hashes for identical work, which + /// is the only condition under which comparing their speed means anything. + pub identical_output: bool, +} + +#[cfg(feature = "ocl")] +impl AbReport { + fn sorted_ratios(&self) -> Vec { + let mut r: Vec = self.pairs.iter().map(|p| p.ratio).collect(); + r.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + r + } + pub fn median_ratio(&self) -> f64 { + let r = self.sorted_ratios(); + if r.is_empty() { + return 0.0; + } + let mid = r.len() / 2; + if r.len() % 2 == 0 { + (r[mid - 1] + r[mid]) / 2.0 + } else { + r[mid] + } + } + /// Peak-to-peak spread of the PAIRED ratio. + pub fn ratio_spread_pct(&self) -> f64 { + let r = self.sorted_ratios(); + match (r.first(), r.last()) { + (Some(lo), Some(hi)) if *lo > 0.0 => (hi - lo) / self.median_ratio() * 100.0, + _ => 0.0, + } + } + /// Middle-80% spread of the paired ratio. THIS is the resolution of an A/B + /// comparison. Peak-to-peak is reported too, but it is dominated by the + /// occasional OS or driver hitch: one stalled leg in a dozen turns a 0.3% + /// spread into 7%, which would set an absurd bar and hide real wins. + pub fn ratio_p10_p90_pct(&self) -> f64 { + spread_p10_p90(&self.sorted_ratios(), self.median_ratio()) + } + + pub fn render(&self) -> String { + let mut text = format!( + " A : {}\n B : {}\n \ + shape {}x{}x{} ({} nonces/batch), height {}, {} batches per leg ({} nonces)\n \ + legs are ALTERNATED A,B,A,B... in one process, so both see the same clock state\n \ + identical output : {}\n CPU checks : {}\n", + self.dir_a, + self.dir_b, + self.shape.work_groups, + self.shape.local_size, + self.shape.unit_size, + self.shape.nonces(), + self.height, + self.batches_per_leg, + self.nonces_per_leg, + if self.identical_output { + "yes - A and B returned byte-identical hashes for the same work" + } else { + "NO - the two builds do not agree; the speed comparison is meaningless" + }, + self.cpu_checks, + ); + for (i, pair) in self.pairs.iter().enumerate() { + text.push_str(&format!( + " pair {:>2} : A {:>7.3}s B {:>7.3}s B/A = {:.4}\n", + i + 1, + pair.a_seconds, + pair.b_seconds, + pair.ratio + )); + } + text.push_str(&format!( + " median B/A : {:.4} ({:+.2}%)\n \ + paired p10-p90 : {:.2}% <-- the resolution of this A/B test\n \ + paired peak-peak : {:.2}% (inflated by any single OS/driver hitch)\n", + self.median_ratio(), + (self.median_ratio() - 1.0) * 100.0, + self.ratio_p10_p90_pct(), + self.ratio_spread_pct(), + )); + text + } +} + +/// Paired A/B between two kernel trees on the same card, in one process. +/// +/// Why paired. Two separate baseline processes on this rig disagree by ~2.5% +/// even on identical work, because the card settles into one of a couple of +/// clock/power states and stays there for the life of the process. That drift +/// is larger than most kernel changes worth making, so comparing the medians of +/// two separate runs cannot resolve them. Alternating the two builds inside one +/// process puts both legs in the same power state within seconds of each other, +/// and the ratio cancels the drift. +/// +/// Run it with `dir_b == dir_a` first. The median ratio must be 1.000 and the +/// paired spread is then the floor of the method itself. +#[cfg(feature = "ocl")] +#[allow(clippy::too_many_arguments)] +pub fn run_ab( + dir_a: &str, + dir_b: &str, + platform: u32, + device: &str, + shape: Shape, + height: u64, + batches_per_leg: u32, + pairs: u32, + warmup_batches: u32, + headers: u32, +) -> Result { + let a = open_device(dir_a, platform, device, shape)?; + let b = open_device(dir_b, platform, device, shape)?; + let per_batch = shape.nonces(); + let headers = headers.max(1); + let intros: Vec> = (0..headers).map(corpus_header).collect(); + let nonce_start = 0x1000_0000u32; + + let leg = |res: &OpenCLResources| -> Result<(f64, Vec<(u32, [u8; 32], u32)>), String> { + let started = Instant::now(); + let mut out = Vec::with_capacity(batches_per_leg as usize); + for batch in 0..batches_per_leg { + let header_index = batch % headers; + let start = nonce_start.wrapping_add(batch.wrapping_mul(per_batch as u32)); + let (nonce, hash) = crate::opencl_gpu::block::do_group_block_mining_opencl( + res, + height, + intros[header_index as usize].clone(), + start, + shape.work_groups, + shape.local_size, + shape.unit_size, + ) + .map_err(|e| e.display())?; + out.push((nonce, hash, header_index)); + } + Ok((started.elapsed().as_secs_f64(), out)) + }; + + for w in 0..warmup_batches { + let start = 0xF000_0000u32.wrapping_add(w.wrapping_mul(per_batch as u32)); + let res = if w % 2 == 0 { &a } else { &b }; + crate::opencl_gpu::block::do_group_block_mining_opencl( + res, + height, + intros[0].clone(), + start, + shape.work_groups, + shape.local_size, + shape.unit_size, + ) + .map_err(|e| format!("warm-up batch {}: {}", w + 1, e.display()))?; + } + + let mut out_pairs = Vec::with_capacity(pairs as usize); + let mut cpu_checks = 0u32; + let mut identical_output = true; + for pair in 0..pairs { + // Alternate which leg goes first. Running A first every time gave a + // reproducible +0.11% edge to B on this rig when both trees were + // identical, i.e. a bias of the method, not of the kernel. Swapping the + // order on alternate pairs cancels it instead of leaving it to be + // mistaken for a 0.1% win. + let (a_seconds, a_out, b_seconds, b_out) = if pair % 2 == 0 { + let (a_seconds, a_out) = leg(&a)?; + let (b_seconds, b_out) = leg(&b)?; + (a_seconds, a_out, b_seconds, b_out) + } else { + let (b_seconds, b_out) = leg(&b)?; + let (a_seconds, a_out) = leg(&a)?; + (a_seconds, a_out, b_seconds, b_out) + }; + if a_seconds <= 0.0 || b_seconds <= 0.0 { + return Err("non-positive leg duration".to_string()); + } + // Same work, so the two legs must return the same answers. A speed + // comparison between builds that disagree is worthless. + if a_out.len() != b_out.len() + || a_out + .iter() + .zip(b_out.iter()) + .any(|(x, y)| x.0 != y.0 || x.1 != y.1) + { + identical_output = false; + } + for (nonce, hash, header_index) in &a_out { + if cpu_hash(height, &intros[*header_index as usize], *nonce) != *hash { + return Err(format!("leg A returned a wrong hash at nonce {nonce}")); + } + cpu_checks += 1; + } + out_pairs.push(AbPair { + a_seconds, + b_seconds, + ratio: a_seconds / b_seconds, + }); + } + + Ok(AbReport { + dir_a: dir_a.to_string(), + dir_b: dir_b.to_string(), + shape, + height, + batches_per_leg, + nonces_per_leg: per_batch.saturating_mul(batches_per_leg as u64), + pairs: out_pairs, + cpu_checks, + identical_output, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corpus_is_deterministic_and_respects_the_kernel_padding() { + for index in 0..8 { + let a = corpus_header(index); + let b = corpus_header(index); + assert_eq!(a, b, "corpus header {index} must be reproducible"); + assert_eq!(a.len(), BLOCK_INTRO_BYTES); + assert_eq!( + a[BLOCK_INTRO_BYTES - 1], + 0, + "byte 88 must be zero or the card and the CPU hash different messages" + ); + } + assert_ne!(corpus_header(0), corpus_header(1)); + } + + #[test] + fn gate_heights_cover_the_intended_repeats() { + let repeats: Vec = GATE_HEIGHTS + .iter() + .map(|h| x16rs::block_hash_repeat(*h)) + .collect(); + assert_eq!(repeats, vec![1, 4, 8, 16]); + } + + #[test] + fn algo_sequence_has_one_entry_per_round_and_matches_the_first_pick() { + let intro = corpus_header(3); + let seq = algo_sequence(REPEAT16_HEIGHT, &intro, 12_345); + assert_eq!(seq.len(), 16); + assert!(seq.iter().all(|a| *a < 16)); + // Round 0 acts on the sha3 seed itself. + let mut stuff = intro.clone(); + stuff[NONCE_OFFSET..NONCE_OFFSET + 4].copy_from_slice(&12_345u32.to_be_bytes()); + let seed = x16rs::calculate_hash(&stuff); + assert_eq!(seq[0], seed[28] & 0x0f); + } + + /// The oracle's price list, and the fact that the measurement behind it is + /// close enough to what this build really does. + /// + /// `CPU_ORACLE_HPS_PER_CORE` is a measured number that the auto-tuner quotes + /// to operators, so a build where it is wrong by an order of magnitude has + /// to fail here rather than in someone's log. The note this replaced said + /// "a few hundred hashes a second per core", which is wrong by more than two + /// hundred times in a release build and by fifty in a debug one; being that + /// wrong about the oracle is what makes a proof look impossible when it + /// takes four seconds. + /// + /// The check on the live rate is a factor-of-two band, not an equality: this + /// runs on whatever machine the gate runs on, usually while that machine is + /// mining. A factor of two still catches the failure that actually happens, + /// which is a constant nobody re-measured after the kernel changed. + #[test] + fn the_oracle_costs_what_the_tuner_says_it_costs() { + // 64x256x192, the largest launch an RX 9070 XT can be asked for. + let (seconds, bytes) = oracle_cost(3_145_728, 14); + assert!(seconds > 3.5 && seconds < 4.0, "{seconds}"); + assert_eq!(bytes, 3_145_728 * 64); + // A 3072x256x128 launch, which a 3584 work-group preset permits: thirty + // times the work and gigabytes of it. + let (seconds, bytes) = oracle_cost(100_663_296, 14); + assert!(seconds > 110.0 && seconds < 130.0, "{seconds}"); + assert_eq!(bytes / (1024 * 1024), 6_144, "{bytes} bytes"); + // Threads help, and a nonsense thread count does not divide by zero. + assert!(oracle_cost(1_000_000, 0).0 > oracle_cost(1_000_000, 8).0); + assert!(oracle_cost(0, 4).0 == 0.0); + + // 2 000 nonces: 26 ms in release, 123 ms in debug, so this is affordable + // in the build the gate really runs and long enough to be past the + // timer's resolution and the first-call warm-up. + let intro = corpus_header(7); + let count = 2_000u32; + let started = std::time::Instant::now(); + let out = cpu_hash_window(REPEAT16_HEIGHT, &intro, 1_000, count, 1); + let measured = count as f64 / started.elapsed().as_secs_f64(); + assert_eq!(out.len(), count as usize); + assert!( + measured > 1_000.0, + "one core managed {measured:.0} H/s, which is not a working oracle" + ); + if !cfg!(debug_assertions) { + assert!( + measured > CPU_ORACLE_HPS_PER_CORE / 2.0 + && measured < CPU_ORACLE_HPS_PER_CORE * 2.0, + "a release build measures {measured:.0} H/s against the \ + {CPU_ORACLE_HPS_PER_CORE:.0} this constant promises; every proof estimate the \ + tuner prints is out by that factor" + ); + } + } + + #[test] + fn threshold_ranks_are_in_range_and_include_a_full_list() { + let window = 589_824u64; + let ranks = threshold_ranks(window, 1024, 255); + assert!(ranks.contains(&1024), "one launch must fill the share list"); + assert!(ranks.windows(2).all(|w| w[0] < w[1]), "sorted and deduped"); + assert!(ranks.iter().all(|r| *r >= 1 && *r <= window)); + // One wrong hash anywhere in the window has to be unlikely to slip past + // the whole threshold set, or the production pass is decoration. + let miss = threshold_miss_probability(window, &ranks); + assert!(miss < 0.005, "miss probability {miss} is too high"); + // More thresholds must strictly help, and the relationship is ~1/levels. + assert!( + threshold_miss_probability(window, &threshold_ranks(window, 1024, 1023)) < miss / 3.0 + ); + } + + #[test] + fn threaded_oracle_equals_the_single_threaded_one() { + let intro = corpus_header(1); + let single = cpu_hash_window(1, &intro, 900, 40, 1); + let many = cpu_hash_window(1, &intro, 900, 40, 8); + assert_eq!(single, many); + } + + /// Every exhaustive shape must dump a whole window on BOTH backends. + /// + /// `run_equivalence_on` refuses at runtime if a backend's capacity has moved, + /// but that check only fires on a machine with a card. This one fires on any + /// machine, and it is the thing that keeps the CUDA gate as exhaustive as the + /// OpenCL one: if someone changes the share list to 512, the shapes stop + /// covering the window and the "ENTIRE window" line in the report becomes a + /// lie on both cards at once. + #[test] + fn every_exhaustive_shape_fills_the_share_list_exactly() { + for (work_groups, local_size, unit_size) in EXHAUSTIVE_SHAPES { + let shape = Shape { + work_groups, + local_size, + unit_size, + }; + assert_eq!( + shape.nonces(), + SHARE_LIST_CAPACITY, + "{shape} does not dump exactly one full share list" + ); + // CUDA fixes the block size at 256; a shape that broke that would be + // rejected at run time on NVIDIA and silently accepted on AMD, which + // is exactly the drift one gate with two backends is meant to stop. + assert_eq!(shape.local_size, 256, "{shape} is not launchable on CUDA"); + } + // Three DIFFERENT unit_sizes, because unit_size is what varies the + // counting sort's ordering and the histogram contention inside a work + // group. Three copies of one shape would prove a third as much. + let mut units: Vec = EXHAUSTIVE_SHAPES.iter().map(|s| s.2).collect(); + units.sort_unstable(); + units.dedup(); + assert_eq!(units.len(), EXHAUSTIVE_SHAPES.len()); + } + + /// A helper that builds a report as if the card had failed exactly the + /// nonces whose chain runs `broken`, which is what one broken algorithm + /// function does. + fn report_with_broken_algo(broken: u8, height: u64, nonces: u32) -> EquivReport { + let intro = corpus_header(2); + let mut report = EquivReport { + backend: "test".into(), + ..Default::default() + }; + for nonce in 0..nonces { + let chain = algo_sequence(height, &intro, nonce); + let hit = chain.contains(&broken); + if hit { + report.mismatches.push(Mismatch { + height, + repeat: x16rs::block_hash_repeat(height), + header_index: 2, + nonce, + gpu: [0u8; 32], + cpu: [1u8; 32], + algos: chain, + }); + } else { + report.passing_chain_samples += 1; + let mut seen = [false; 16]; + for algo in &chain { + seen[(*algo & 0x0f) as usize] = true; + } + for (index, ran) in seen.iter().enumerate() { + if *ran { + report.passing_algo_chains[index] += 1; + } + } + } + } + report + } + + /// The whole point of the attribution: one broken algorithm, named, at every + /// repeat the gate runs. + /// + /// Repeat 16 is the hard case. An innocent algorithm is in a random chain + /// with probability 1 - (15/16)^16 = 64%, so the "in every failure" + /// intersection alone leaves several suspects; only the "in no passing + /// chain" half removes them. If someone deletes that half, this test fails + /// at 16 and passes at 1, which is the diagnosis printed in the assertion. + #[test] + fn one_broken_algorithm_is_named_at_every_repeat() { + for height in GATE_HEIGHTS { + for broken in 0u8..16 { + let report = report_with_broken_algo(broken, height, 4_000); + let blame = report.attribute(); + assert!( + blame.failing_chains >= MIN_CHAINS_TO_NAME, + "height {height} algo {broken}: only {} failing chains, the corpus is too \ + small to test the attribution", + blame.failing_chains + ); + assert_eq!( + blame.named, + vec![broken], + "height {height} (repeat {}): broken algorithm {} ({}) was not named alone; \ + in every failure = {:?}, named = {:?}", + x16rs::block_hash_repeat(height), + broken, + ALGO_NAMES[broken as usize], + blame.in_every_failure, + blame.named, + ); + assert_eq!( + blame.passing[broken as usize], 0, + "a chain that ran the broken algorithm cannot have passed" + ); + assert!(blame.render().contains(ALGO_NAMES[broken as usize])); + } + } + } + + /// A defect that is not one algorithm must NOT be blamed on one. + /// + /// The barrier fault the OpenCL gate was proved with corrupts hashes without + /// regard to which algorithms they ran. Failing chains then share no + /// algorithm, and the honest report is "no single algorithm is implicated" - + /// which is also the true statement about a race. + #[test] + fn a_defect_that_is_not_one_algorithm_names_nothing() { + let intro = corpus_header(4); + let height = REPEAT16_HEIGHT; + let mut report = EquivReport::default(); + // Every third nonce wrong, chosen with no reference to its chain. + for nonce in 0..900u32 { + let chain = algo_sequence(height, &intro, nonce); + if nonce % 3 == 0 { + report.mismatches.push(Mismatch { + height, + repeat: 16, + header_index: 4, + nonce, + gpu: [0u8; 32], + cpu: [1u8; 32], + algos: chain, + }); + } else { + report.passing_chain_samples += 1; + let mut seen = [false; 16]; + for algo in &chain { + seen[(*algo & 0x0f) as usize] = true; + } + for (index, ran) in seen.iter().enumerate() { + if *ran { + report.passing_algo_chains[index] += 1; + } + } + } + } + let blame = report.attribute(); + assert!( + blame.named.is_empty(), + "a race was blamed on {:?}", + blame.named + ); + assert!(blame.render().contains("NO single algorithm")); + } + + /// The passing-chain half of the attribution, pinned where it does the work. + /// + /// On a thin run the intersection of the failing chains is NOT one algorithm + /// - here it is six - and only "ran in no chain the card got right" cuts it + /// to the culprit. Delete that half and this test reports the six. + #[test] + fn the_passing_chain_filter_is_what_cuts_the_suspects_down() { + // 6 corpus nonces at repeat 16, skein (5) broken: three chains fail. + let report = report_with_broken_algo(5, REPEAT16_HEIGHT, 6); + let blame = report.attribute(); + assert_eq!(blame.failing_chains, 3); + assert!( + blame.in_every_failure.len() > 1, + "the intersection alone was already unique, so this test proves nothing: {:?}", + blame.in_every_failure + ); + assert!(blame.in_every_failure.contains(&5)); + assert_eq!( + blame.named, + vec![5], + "the passing-chain filter should have cut {:?} to skein alone", + blame.in_every_failure + ); + } + + /// Too few failures must produce a candidate list, not a confident name. + #[test] + fn a_handful_of_mismatches_names_nothing() { + let report = report_with_broken_algo(9, REPEAT16_HEIGHT, 4); + let blame = report.attribute(); + assert!(blame.failing_chains < MIN_CHAINS_TO_NAME); + assert!(blame.render().contains("too few failing chains")); + } + + /// A clean run must not accuse anyone, and must print nothing about blame. + #[test] + fn a_passing_run_attributes_nothing() { + let mut report = EquivReport { + compared: 10_000, + passing_chain_samples: 150, + ..Default::default() + }; + report.algo_counts = [40; 16]; + report.passing_algo_chains = [90; 16]; + assert!(report.passed()); + let blame = report.attribute(); + assert_eq!(blame.failing_chains, 0); + assert!(blame.named.is_empty()); + assert_eq!(blame.render(), ""); + } + + /// A gate that compared nothing, or that never reached an algorithm, is not + /// a pass. This is the property that makes a CUDA run on a machine with no + /// device fail instead of printing an empty PASS. + #[test] + fn an_empty_or_incomplete_run_is_not_a_pass() { + assert!(!EquivReport::default().passed()); + let mut compared_nothing = EquivReport { + compared: 0, + ..Default::default() + }; + compared_nothing.algo_counts = [1; 16]; + assert!(!compared_nothing.passed()); + let mut missed_one = EquivReport { + compared: 5_000, + ..Default::default() + }; + missed_one.algo_counts = [7; 16]; + missed_one.algo_counts[11] = 0; + assert!(!missed_one.passed()); + assert!(missed_one.render().contains("NEVER TESTED")); + } +} + +// --------------------------------------------------------------------------- +// The launch-fit rules, which are what stop a measurement being attributed to a +// shape that never ran. Compiled and tested without a card, on purpose: neither +// rule needs one, and both are the kind of thing that is only ever exercised on +// hardware nobody has. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod launch_fit_tests { + use super::*; + + fn shape(work_groups: u32, local_size: u32, unit_size: u32) -> Shape { + Shape { + work_groups, + local_size, + unit_size, + } + } + + #[test] + fn a_per_call_backend_launches_anything_that_fits_its_buffers() { + let allocated = shape(64, 256, 192); + // The whole reason the auto-tuner opens one OpenCL device for a session: + // every smaller point on the grid runs against the same allocation. + assert!(launch_fits_allocation(allocated, allocated).is_ok()); + assert!(launch_fits_allocation(allocated, shape(32, 256, 64)).is_ok()); + assert!(launch_fits_allocation(allocated, shape(64, 256, 32)).is_ok()); + + // Larger in either axis writes past global_hashes / global_order. + assert!(launch_fits_allocation(allocated, shape(96, 256, 192)).is_err()); + assert!(launch_fits_allocation(allocated, shape(64, 256, 256)).is_err()); + // A different block size is a different indexing, not a smaller launch. + assert!(launch_fits_allocation(allocated, shape(64, 128, 64)).is_err()); + } + + #[test] + fn a_bound_backend_refuses_the_launch_that_would_lie_about_its_shape() { + let allocated = shape(256, 256, 64); + assert!(launch_fits_bound_allocation(allocated, allocated).is_ok()); + // Work groups ARE a per-launch parameter on this backend, so fewer of + // them is a real launch of fewer and one miner serves the whole + // work-group axis at its unit_size. + assert!(launch_fits_bound_allocation(allocated, shape(128, 256, 64)).is_ok()); + assert!(launch_fits_bound_allocation(allocated, shape(48, 256, 64)).is_ok()); + + // unit_size is not, and this is the case with no other line of defence: + // the kernel would run 64, return hashes that are correct FOR 64, pass + // every equivalence proof, and hand back a time the caller would file + // under 128. Only the time would be wrong, so only refusing the launch + // catches it. + let mislabelled = launch_fits_bound_allocation(allocated, shape(256, 256, 128)); + let message = mislabelled.expect_err("a unit_size the miner was not built with"); + assert!(message.contains("would run unit_size 64"), "{message}"); + assert!(message.contains("reported as 128"), "{message}"); + + assert!(launch_fits_bound_allocation(allocated, shape(512, 256, 64)).is_err()); + assert!(launch_fits_bound_allocation(allocated, shape(256, 128, 64)).is_err()); + } + + #[test] + fn the_two_rules_disagree_exactly_where_the_backends_do() { + // One shape, two backends, opposite answers, and that difference is the + // entire reason `device_is_bound_to_its_shape` exists. A tuner that used + // the permissive rule on the strict backend would measure the same + // unit_size over and over and write a shape it never ran. + let allocated = shape(256, 256, 128); + let smaller_unit = shape(256, 256, 64); + assert!(launch_fits_allocation(allocated, smaller_unit).is_ok()); + assert!(launch_fits_bound_allocation(allocated, smaller_unit).is_err()); + } +} diff --git a/app/tests/shipped_worker_configs.rs b/app/tests/shipped_worker_configs.rs new file mode 100644 index 00000000..f18fdb19 --- /dev/null +++ b/app/tests/shipped_worker_configs.rs @@ -0,0 +1,231 @@ +//! The config files that ship with a release are the defaults every operator +//! who never opens the GUI actually runs on. They are text, so nothing compiles +//! them and nothing checked them; this does. +//! +//! Two things are checked, and both of them shipped broken: +//! +//! 1. A HACD config must not pin a thread count. A number in a file is a guess +//! about a machine the file has never seen. The one that shipped was 6, +//! which on the measured 16-core / 32-thread CPU is 320,097 H/s against +//! 1,442,210 for the count the worker now derives: 22% of the machine. +//! +//! 2. No config may say `dynamic_supervene = true` next to `supervene_max = 0`. +//! `EfficiencyConf::spawn_supervene` ignores the flag entirely unless the cap +//! is above zero, so that pair is a setting that reads as on and is off. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app/ has a parent") + .to_path_buf() +} + +/// Flat key/value view of an ini, section names ignored. Every key checked here +/// is unique across the file, and reading it flat means a key that moves between +/// `[efficiency]` and the root still gets checked. +fn ini_pairs(text: &str) -> HashMap { + let mut out = HashMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with(';') || line.starts_with('#') || line.starts_with('[') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + out.insert( + k.trim().to_ascii_lowercase(), + v.split(';').next().unwrap_or(v).trim().to_string(), + ); + } + } + out +} + +/// The `.bat` setup scripts write their default config with `echo key = value` +/// lines, so the same reader works on them once the `echo` is stripped. +fn bat_generated_pairs(text: &str, label: &str) -> HashMap { + let body: String = text + .lines() + .filter_map(|l| { + let t = l.trim(); + t.strip_prefix("echo ").map(|rest| rest.to_string()) + }) + .collect::>() + .join("\n"); + let pairs = ini_pairs(&body); + assert!( + pairs.contains_key("supervene"), + "{label} writes no supervene line at all" + ); + pairs +} + +fn push_ini_files(dir: PathBuf, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name.ends_with(".ini") || name.ends_with(".ini.example") { + files.push(path); + } + } +} + +/// Every ini or ini-shaped file a release hands an operator. +fn shipped_config_files() -> Vec { + let root = repo_root(); + let mut files = Vec::new(); + for dir in [ + "mainnet-configs", + "scripts/mining-amd", + "scripts/mining-nvidia", + "scripts/mining-amd/presets/diaworker", + "scripts/mining-amd/presets/poworker", + ] { + push_ini_files(root.join(dir), &mut files); + } + files.sort(); + files +} + +/// A flag that reads as on and is off is worse than one that is off: an +/// operator who wanted dynamic CPU assist ticked it, got nothing, and had no +/// way to find out. +#[test] +fn no_shipped_config_claims_dynamic_supervene_it_cannot_perform() { + let files = shipped_config_files(); + assert!( + files.len() >= 20, + "expected to find the shipped configs, found {}", + files.len() + ); + let mut checked = 0usize; + for path in &files { + let text = std::fs::read_to_string(path).expect("readable"); + let pairs = ini_pairs(&text); + let Some(dynamic) = pairs.get("dynamic_supervene") else { + continue; + }; + if !matches!(dynamic.as_str(), "true" | "1" | "yes") { + continue; + } + checked += 1; + let max: u32 = pairs + .get("supervene_max") + .map(|v| v.parse().unwrap_or(0)) + .unwrap_or(0); + assert!( + max > 0, + "{}: dynamic_supervene = true with supervene_max = {max}. \ + spawn_supervene ignores the flag unless the cap is above zero, \ + so this setting does nothing.", + path.display() + ); + } + assert!( + checked > 0, + "no shipped config enables dynamic_supervene, so this test proved nothing" + ); +} + +/// The HACD worker owns the whole CPU, and the right number is a property of the +/// machine. A file cannot know it, so a shipped HACD config must decline to +/// guess: `supervene = 0` is read by `DiaWorkConf::new` as "fit this machine". +/// +/// The per-machine presets under `scripts/mining-amd/presets/` are excluded on +/// purpose: naming a CPU in the filename is exactly the case where a number IS +/// knowledge rather than a guess. +#[test] +fn shipped_hacd_configs_do_not_pin_a_thread_count() { + let root = repo_root(); + let generic = [ + root.join("mainnet-configs/diaworker.mainnet.ini"), + root.join("scripts/mining-amd/diaworker.amd.ini.example"), + ]; + for path in &generic { + let text = + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let pairs = ini_pairs(&text); + let sv = pairs + .get("supervene") + .unwrap_or_else(|| panic!("{}: no supervene key", path.display())); + assert_eq!( + sv, + "0", + "{}: ships supervene = {sv}. A generic HACD config must not pin a \ + thread count; 0 means the worker counts this machine's cores.", + path.display() + ); + } + + for (name, label) in [ + ("SETUP.bat", "SETUP.bat"), + ("SETUP-MINER.bat", "SETUP-MINER.bat"), + ] { + let text = std::fs::read_to_string(root.join(name)).expect("readable"); + // Only the diaworker generator: the poworker one in the same file writes + // its own supervene and is a different question. The label is matched at + // the start of a line, because `call :write_default_diaworker_ini` + // appears earlier in the file and is not the subroutine. + let start = text + .find("\n:write_default_diaworker_ini") + .unwrap_or_else(|| panic!("{label}: no diaworker generator")); + let block = &text[start..]; + let end = block.find("exit /b 0").unwrap_or(block.len()); + let pairs = bat_generated_pairs(&block[..end], label); + assert_eq!( + pairs.get("supervene").map(String::as_str), + Some("0"), + "{label} writes a pinned HACD thread count: {:?}", + pairs.get("supervene") + ); + assert_eq!( + pairs.get("dynamic_supervene").map(String::as_str), + Some("false"), + "{label} writes dynamic_supervene for a worker with no GPU to balance against" + ); + } +} + +/// What the shipped file means, read through the code that reads it. The ini +/// text and `DiaWorkConf` are two halves of one decision, and this is the seam +/// where a rename or a changed default would part them without any test failing. +#[test] +fn the_shipped_hacd_config_resolves_to_this_machines_thread_count() { + let path = repo_root().join("mainnet-configs/diaworker.mainnet.ini"); + assert!(path.is_file(), "{} is missing", path.display()); + let ini = sys::load_config_path(&path); + // `load_config_path` answers an unreadable file with an EMPTY ini, and an + // empty ini would satisfy every assertion below for the wrong reason. + assert_eq!( + sys::ini_must(&sys::ini_section(&ini, "default"), "connect", ""), + "127.0.0.1:8080", + "the shipped config did not parse, so nothing below was really tested" + ); + let cnf = app::diaworker::DiaWorkConf::new(&ini); + + let expected = app::cpu_threads::hacd_threads(); + assert_eq!(cnf.supervene, expected); + assert_eq!(cnf.efficiency.clamp_supervene(cnf.supervene), expected); + assert_eq!(cnf.efficiency.spawn_supervene(cnf.supervene), expected); + + let logical = app::cpu_threads::logical_cpus(); + if logical > app::cpu_threads::HOST_RESERVE_THREADS { + assert_eq!( + cnf.supervene, + logical - app::cpu_threads::HOST_RESERVE_THREADS, + "the shipped config must leave exactly the host reserve free" + ); + } + // The number that used to ship, and the number the panel used to offer. + // Both are only correct on a machine that happens to have that many cores. + if logical >= 16 { + assert_ne!(cnf.supervene, 6); + assert_ne!(cnf.supervene, 8); + } + assert!(!cnf.useopencl, "HACD stays CPU-only"); +} diff --git a/basis/src/config/engine.rs b/basis/src/config/engine.rs index 5ab6d04d..92752bf4 100644 --- a/basis/src/config/engine.rs +++ b/basis/src/config/engine.rs @@ -1,15 +1,15 @@ - - - - - /// Strict variant of `ini_must_u64` for the keys where silently falling back to the default /// picks the wrong chain or the wrong limit. `ini_must_u64` cannot tell "key absent" from /// "key present but unparseable" and returns the default for both, so a typo like /// `chain_id = 5abc` would resolve to 0, which is mainnet, and the node would run mainnet /// rules while the operator believes it is on a side chain. An absent or empty value still /// takes the default; anything present but not a number is a hard startup failure. -fn engine_ini_u64_strict(sec: &HashMap>, sec_name: &str, key: &str, dv: u64) -> u64 { +fn engine_ini_u64_strict( + sec: &HashMap>, + sec_name: &str, + key: &str, + dv: u64, +) -> u64 { let Some(raw) = sec.get(key).and_then(|v| v.as_deref()) else { return dv; }; @@ -32,7 +32,6 @@ fn engine_ini_u64_strict(sec: &HashMap>, sec_name: &str, } } - #[derive(Clone)] pub struct EngineConf { pub max_block_txs: usize, @@ -40,6 +39,13 @@ pub struct EngineConf { pub max_tx_size: usize, pub max_tx_actions: usize, pub chain_id: u32, // sub chain id + /// Optional HPAY capability identity for an explicitly configured private + /// development chain. Empty on mainnet and on unidentified side chains. + pub network_kind: String, + pub node_profile_id: String, + /// Optional public address whose confirmed Local Pilot balance is used as + /// one input to the fail-closed transaction-readiness proof. + pub pilot_funding_address: Option
, pub unstable_block: u64, // The number of blocks that are likely to fall back from the fork pub fast_sync: bool, pub sync_maxh: u64, // sync max height, limit @@ -59,7 +65,7 @@ pub struct EngineConf { pub diamond_form: bool, pub recent_blocks: bool, pub average_fee_purity: bool, - pub lowest_fee_purity: u64, + pub lowest_fee_purity: u64, // hac miner pub miner_enable: bool, pub miner_reward_address: Address, @@ -68,8 +74,8 @@ pub struct EngineConf { pub dmer_enable: bool, pub dmer_reward_address: Address, pub dmer_bid_account: Account, - pub dmer_bid_min: Amount, - pub dmer_bid_max: Amount, + pub dmer_bid_min: Amount, + pub dmer_bid_max: Amount, pub dmer_bid_step: Amount, // tx pool pub txpool_maxs: Vec, @@ -78,9 +84,7 @@ pub struct EngineConf { pub contract_cache_size: f64, } - impl EngineConf { - pub fn is_open_miner(&self) -> bool { self.miner_enable || self.dmer_enable } @@ -94,14 +98,12 @@ impl EngineConf { // otherwise keep zero-address semantics. pub fn external_exec_author(&self) -> Address { if self.miner_enable && self.miner_reward_address != Address::default() { - return self.miner_reward_address + return self.miner_reward_address; } Address::default() } - - pub fn new(ini: &IniObj) -> EngineConf { - + pub fn new(ini: &IniObj) -> EngineConf { // datadir let data_dir = get_mainnet_data_dir(ini); @@ -112,12 +114,15 @@ impl EngineConf { // fee_purity is now per-byte: 1:244 = 1000000:238, purity = 1000000 / 166 ≈ 6024 const LOWEST_FEE_PURITY: u64 = 10000_00 / 166; // 6024 - let mut cnf = EngineConf{ + let mut cnf = EngineConf { max_block_txs: 1000, - max_block_size: 1024*1024*1, // 1MB - max_tx_size: 1024 * 16, // 16kb - max_tx_actions: 200, // 200 + max_block_size: 1024 * 1024 * 1, // 1MB + max_tx_size: 1024 * 16, // 16kb + max_tx_actions: 200, // 200 chain_id: 0, + network_kind: String::new(), + node_profile_id: String::new(), + pilot_funding_address: None, unstable_block: 4, // 4 block fast_sync: false, sync_maxh: 0, @@ -145,8 +150,8 @@ impl EngineConf { dmer_enable: false, dmer_reward_address: Address::default(), dmer_bid_account: Account::create_by_password("123456").unwrap(), - dmer_bid_min: Amount::small_mei(1), - dmer_bid_max: Amount::small_mei(31), + dmer_bid_min: Amount::small_mei(1), + dmer_bid_max: Amount::small_mei(31), dmer_bid_step: Amount::small(5, 247), // tx pool txpool_maxs: Vec::default(), @@ -155,8 +160,12 @@ impl EngineConf { }; // setup lowest_fee if ini_must(sec_server, "lowest_fee", "").trim().len() > 0 { - let lfepr = ini_must_amount_required(sec_server, "server", "lowest_fee").compress(2, AmtCpr::Grow) - .unwrap().to_238_u64().unwrap() / 166; // =6024, simple hac trs size + let lfepr = ini_must_amount_required(sec_server, "server", "lowest_fee") + .compress(2, AmtCpr::Grow) + .unwrap() + .to_238_u64() + .unwrap() + / 166; // =6024, simple hac trs size cnf.lowest_fee_purity = lfepr; println!("[Config] node accepted lowest fee purity {}.", lfepr); } @@ -169,12 +178,49 @@ impl EngineConf { // checked: `as u32` alone would fold 4294967296 back to 0, which is mainnet. let chain_id = engine_ini_u64_strict(sec_mint, "mint", "chain_id", 0); if chain_id > u32::MAX as u64 { - panic!("[Config Error] [mint] chain_id {} is out of range, it must be between 0 and {}.", - chain_id, u32::MAX) + panic!( + "[Config Error] [mint] chain_id {} is out of range, it must be between 0 and {}.", + chain_id, + u32::MAX + ) } cnf.chain_id = chain_id as u32; + cnf.network_kind = ini_must_maxlen(sec_mint, "network_kind", "", 32) + .trim() + .to_owned(); + cnf.node_profile_id = ini_must_maxlen(sec_mint, "node_profile_id", "", 64) + .trim() + .to_owned(); + if cnf.chain_id == 0 && (!cnf.network_kind.is_empty() || !cnf.node_profile_id.is_empty()) { + panic!("[Config Error] mainnet must not declare a private network identity") + } + if cnf.network_kind.is_empty() != cnf.node_profile_id.is_empty() { + panic!("[Config Error] network_kind and node_profile_id must be configured together") + } + if !cnf.network_kind.is_empty() + && (cnf.chain_id != 7 + || cnf.network_kind != "local_pilot_v1" + || cnf.node_profile_id != "hpay-local-pilot-chain-v1") + { + panic!("[Config Error] unsupported private network identity") + } + let pilot_funding_address = ini_must(sec_mint, "pilot_funding_address", "") + .trim() + .to_owned(); + if !pilot_funding_address.is_empty() { + if cnf.network_kind != "local_pilot_v1" { + panic!("[Config Error] pilot_funding_address requires local_pilot_v1") + } + let address = Address::from_readable(&pilot_funding_address) + .unwrap_or_else(|_| panic!("[Config Error] pilot_funding_address is invalid")); + if !address.is_privakey() || address.is_privakey_unknown() { + panic!("[Config Error] pilot_funding_address must be a controlled PRIVAKEY address") + } + cnf.pilot_funding_address = Some(address); + } cnf.sync_maxh = engine_ini_u64_strict(sec_mint, "mint", "height_max", 0); - cnf.dev_count_switch = engine_ini_u64_strict(sec_mint, "mint", "dev_count_switch", 0) as usize; + cnf.dev_count_switch = + engine_ini_u64_strict(sec_mint, "mint", "dev_count_switch", 0) as usize; cnf.show_miner_name = ini_must_bool(sec_mint, "show_miner_name", false); let sec_vm = &ini_section(ini, "vm"); @@ -189,12 +235,18 @@ impl EngineConf { if cnf.miner_enable { cnf.miner_reward_address = ini_must_address_required(sec_miner, "miner", "reward"); if !cnf.miner_reward_address.is_privakey() { - panic!("miner reward address {} must be PRIVAKEY type but got version {}", - cnf.miner_reward_address.to_readable(), cnf.miner_reward_address.version()) + panic!( + "miner reward address {} must be PRIVAKEY type but got version {}", + cnf.miner_reward_address.to_readable(), + cnf.miner_reward_address.version() + ) } let msg = ini_must_maxlen(sec_miner, "message", "", 16); - let msgapp = vec![' ' as u8].repeat(16-msg.len()); - let msg: [u8; 16] = vec![msg.as_bytes().to_vec(), msgapp].concat().try_into().unwrap(); + let msgapp = vec![' ' as u8].repeat(16 - msg.len()); + let msg: [u8; 16] = vec![msg.as_bytes().to_vec(), msgapp] + .concat() + .try_into() + .unwrap(); cnf.miner_message = Fixed16::from_readable(&msg).unwrap(); } @@ -204,13 +256,22 @@ impl EngineConf { if cnf.dmer_enable { cnf.dmer_reward_address = ini_must_address_required(sec_dmer, "diamondminer", "reward"); if !cnf.dmer_reward_address.is_privakey() { - panic!("diamond miner reward address {} must be PRIVAKEY type but got version {}", - cnf.dmer_reward_address.to_readable(), cnf.dmer_reward_address.version()) + panic!( + "diamond miner reward address {} must be PRIVAKEY type but got version {}", + cnf.dmer_reward_address.to_readable(), + cnf.dmer_reward_address.version() + ) } cnf.dmer_bid_account = ini_must_account_required(sec_dmer, "bid_password"); - cnf.dmer_bid_min = ini_must_amount_required(sec_dmer, "diamondminer", "bid_min").compress(2, AmtCpr::Grow).unwrap(); - cnf.dmer_bid_max = ini_must_amount_required(sec_dmer, "diamondminer", "bid_max").compress(2, AmtCpr::Grow).unwrap(); - cnf.dmer_bid_step = ini_must_amount_required(sec_dmer, "diamondminer", "bid_step").compress(2, AmtCpr::Grow).unwrap(); + cnf.dmer_bid_min = ini_must_amount_required(sec_dmer, "diamondminer", "bid_min") + .compress(2, AmtCpr::Grow) + .unwrap(); + cnf.dmer_bid_max = ini_must_amount_required(sec_dmer, "diamondminer", "bid_max") + .compress(2, AmtCpr::Grow) + .unwrap(); + cnf.dmer_bid_step = ini_must_amount_required(sec_dmer, "diamondminer", "bid_step") + .compress(2, AmtCpr::Grow) + .unwrap(); } // tx pool @@ -219,12 +280,17 @@ impl EngineConf { // node's pool from 2000 to 100 slots whenever the key was simply absent. let sec_txpool = &ini_section(ini, "txpool"); let txpool_maxs = ini_must(sec_txpool, "maxs", "").replace(" ", ""); - cnf.txpool_maxs = txpool_maxs.split(",").filter(|a| !a.is_empty()).map(|a|{ - match a.parse::() { + cnf.txpool_maxs = txpool_maxs + .split(",") + .filter(|a| !a.is_empty()) + .map(|a| match a.parse::() { Ok(n) => n, - _ => panic!("[Config Error] [txpool] maxs entry {:?} is not a valid number.", a), - } - }).collect(); + _ => panic!( + "[Config Error] [txpool] maxs entry {:?} is not a valid number.", + a + ), + }) + .collect(); // vm contract cache (performance-only), unit: MB let sec_vm = &ini_section(ini, "vm"); @@ -233,7 +299,6 @@ impl EngineConf { // ok cnf } - } #[cfg(test)] @@ -290,11 +355,17 @@ mod tests { "diamondminer".to_owned(), HashMap::from([ ("enable".to_owned(), Some("true".to_owned())), - ("reward".to_owned(), Some("1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9".to_owned())), + ( + "reward".to_owned(), + Some("1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9".to_owned()), + ), ]), ); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); - assert!(res.is_err(), "an unset bid_password must not build a spending account"); + assert!( + res.is_err(), + "an unset bid_password must not build a spending account" + ); } #[test] @@ -309,7 +380,10 @@ mod tests { ]), ); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); - assert!(res.is_err(), "a non PRIVAKEY miner reward must be rejected at config load"); + assert!( + res.is_err(), + "a non PRIVAKEY miner reward must be rejected at config load" + ); } fn ini_of(section: &str, pairs: &[(&str, Option<&str>)]) -> IniObj { @@ -339,20 +413,24 @@ mod tests { assert!( res.is_err(), "reward {:?} must stop startup, never mine the coinbase to {}", - reward, OLD_DEFAULT_REWARD + reward, + OLD_DEFAULT_REWARD ); } } #[test] fn diamond_miner_refuses_to_start_without_a_reward_address() { - let ini = ini_of("diamondminer", &[ - ("enable", Some("true")), - ("bid_password", Some("a-real-wallet-password")), - ("bid_min", Some("1")), - ("bid_max", Some("2")), - ("bid_step", Some("1:244")), - ]); + let ini = ini_of( + "diamondminer", + &[ + ("enable", Some("true")), + ("bid_password", Some("a-real-wallet-password")), + ("bid_min", Some("1")), + ("bid_max", Some("2")), + ("bid_step", Some("1:244")), + ], + ); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); assert!( res.is_err(), @@ -376,8 +454,11 @@ mod tests { assert_eq!(cnf.dmer_enable, true); // dropping any single bid amount must stop startup instead of bidding a placeholder for missing in ["bid_min", "bid_max", "bid_step"] { - let pairs: Vec<(&str, Option<&str>)> = - full.iter().filter(|(k, _)| *k != missing).cloned().collect(); + let pairs: Vec<(&str, Option<&str>)> = full + .iter() + .filter(|(k, _)| *k != missing) + .cloned() + .collect(); let ini = ini_of("diamondminer", &pairs); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); assert!(res.is_err(), "a missing {} must stop startup", missing); @@ -388,7 +469,10 @@ mod tests { fn unparseable_chain_id_must_not_silently_become_mainnet() { let ini = ini_of("mint", &[("chain_id", Some("5abc"))]); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); - assert!(res.is_err(), "a typo in chain_id must not run mainnet rules by accident"); + assert!( + res.is_err(), + "a typo in chain_id must not run mainnet rules by accident" + ); } #[test] @@ -396,7 +480,10 @@ mod tests { // 4294967296 folds back to 0 = mainnet under a bare `as u32` let ini = ini_of("mint", &[("chain_id", Some("4294967296"))]); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); - assert!(res.is_err(), "an out of range chain_id must not wrap around to mainnet"); + assert!( + res.is_err(), + "an out of range chain_id must not wrap around to mainnet" + ); } #[test] @@ -413,6 +500,34 @@ mod tests { assert_eq!(cnf.is_mainnet(), false); } + #[test] + fn explicit_local_pilot_identity_is_exact_and_non_mainnet() { + let cnf = EngineConf::new(&ini_of( + "mint", + &[ + ("chain_id", Some("7")), + ("network_kind", Some("local_pilot_v1")), + ("node_profile_id", Some("hpay-local-pilot-chain-v1")), + ], + )); + assert!(!cnf.is_mainnet()); + assert_eq!(cnf.network_kind, "local_pilot_v1"); + assert_eq!(cnf.node_profile_id, "hpay-local-pilot-chain-v1"); + } + + #[test] + #[should_panic(expected = "unsupported private network identity")] + fn local_pilot_label_cannot_be_attached_to_another_chain_id() { + let _ = EngineConf::new(&ini_of( + "mint", + &[ + ("chain_id", Some("8")), + ("network_kind", Some("local_pilot_v1")), + ("node_profile_id", Some("hpay-local-pilot-chain-v1")), + ], + )); + } + #[test] fn unparseable_mint_and_vm_numbers_are_rejected() { let cases: [(&str, &str); 3] = [ @@ -423,13 +538,21 @@ mod tests { for (section, key) in cases { let ini = ini_of(section, &[(key, Some("12x"))]); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); - assert!(res.is_err(), "[{}] {} = 12x must not fall back to the default", section, key); + assert!( + res.is_err(), + "[{}] {} = 12x must not fall back to the default", + section, + key + ); } } #[test] fn txpool_maxs_stays_empty_when_unset_so_callers_keep_their_own_limits() { - assert_eq!(EngineConf::new(&IniObj::new()).txpool_maxs, Vec::::new()); + assert_eq!( + EngineConf::new(&IniObj::new()).txpool_maxs, + Vec::::new() + ); assert_eq!( EngineConf::new(&ini_of("txpool", &[("maxs", Some(" "))])).txpool_maxs, Vec::::new() @@ -445,6 +568,9 @@ mod tests { assert_eq!(cnf.txpool_maxs, vec![2000usize, 100usize]); let ini = ini_of("txpool", &[("maxs", Some("2000,lots"))]); let res = std::panic::catch_unwind(|| EngineConf::new(&ini)); - assert!(res.is_err(), "a malformed txpool limit must be reported, not defaulted"); + assert!( + res.is_err(), + "a malformed txpool limit must be reported, not defaulted" + ); } } diff --git a/deploy/README.md b/deploy/README.md index abfbd1bf..0beb99ce 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -49,6 +49,25 @@ else's address in it, and anyone who ran it mined into a stranger's wallet. $EDITOR deploy/node/hacash.config.ini # fill in reward = your own address ``` +Set the node's API token, in the same file, and give the same value to the pool. +Both are required and the stack will not come up without them. + +```bash +TOKEN=$(head -c 32 /dev/urandom | base64 | tr -d '/+=') +sed -i "s|^api_token = .*|api_token = $TOKEN|" deploy/node/hacash.config.ini +export HBIT_NODE_API_TOKEN="$TOKEN" # compose reads it from here +``` + +This is not optional and it is not only about access control. The pool runs in a +different container and reaches the node across the compose network, and **the +node refuses to serve its API at all on a non-loopback address with an empty +token**: it prints one line and returns, while the process keeps running and +keeps syncing, looking healthy. The healthcheck would never pass, the pool would +wait on it for ever, and the only symptom would be a pool that never starts. + +Put `export HBIT_NODE_API_TOKEN=...` in your shell profile, or in a `.env` file +beside the compose file, so a reboot does not leave the stack unable to start. + Create the wallet passphrase. It is one half of the wallet; the key file the pool creates is the other, and neither is worth anything alone. @@ -126,18 +145,41 @@ For a host without Docker. Units are in `deploy/systemd/`. ```bash sudo useradd --system --home-dir /var/lib/hbit --shell /usr/sbin/nologin hbit -sudo mkdir -p /opt/hbit /var/lib/hbit/node /var/lib/hbit/pool /etc/hbit +# /opt/hbit/bin, not /opt/hbit: that is the directory both unit files execute +# out of, and installing one level up left every ExecStart pointing at nothing. +sudo mkdir -p /opt/hbit/bin /var/lib/hbit/node /var/lib/hbit/pool /etc/hbit sudo chown -R hbit:hbit /var/lib/hbit -cargo build --locked --release --bin fullnode +# The node binary is `hacash`. It is the same program the release archives ship +# and the same name hacash-node.service runs; building `fullnode` produced a +# file no unit ever looked for. +cargo build --locked --release --bin hacash cargo build --locked --release -p hbit-pool --bin hbit-pool-server --bin hbit-pool-payout -sudo install -m 0755 target/release/fullnode /opt/hbit/ -sudo install -m 0755 target/release/hbit-pool-server /opt/hbit/ -sudo install -m 0755 target/release/hbit-pool-payout /opt/hbit/ -sudo install -m 0755 deploy/hbit-wait-for-node.sh /opt/hbit/ - -sudo install -m 0400 -o hbit -g hbit /dev/null /etc/hbit/wallet-passphrase +sudo install -m 0755 target/release/hacash /opt/hbit/bin/ +sudo install -m 0755 target/release/hbit-pool-server /opt/hbit/bin/ +sudo install -m 0755 target/release/hbit-pool-payout /opt/hbit/bin/ +sudo install -m 0755 deploy/hbit-wait-for-node.sh /opt/hbit/bin/ +# The runbook both unit files point at with Documentation=. Without it, +# "systemctl status" names a file that is not on the machine. +sudo install -D -m 0644 docs/POOL-OPERATOR.md /opt/hbit/docs/POOL-OPERATOR.md + +# The node config the unit names as its only argument. Nothing created it +# before, so the node exited on every start. +sudo install -m 0644 deploy/node/hacash.config.ini /etc/hbit/hacash.config.ini +# On a bare host the pool is on the same machine, so the node's API belongs on +# loopback and needs no token. (In Docker it must bind 0.0.0.0 with a token, +# because the pool is in another container - that is what the shipped file is +# set up for.) +sudo sed -i 's/^bind = .*/bind = 127.0.0.1/' /etc/hbit/hacash.config.ini +sudo sed -i 's/^api_token = .*/; api_token =/' /etc/hbit/hacash.config.ini +# And the reward address the node refuses to start without. Use one you control. +sudo sed -i 's/^reward =.*/reward = YOUR_HAC_ADDRESS/' /etc/hbit/hacash.config.ini + +# Write the passphrase FIRST, then lock the file. Creating it 0400 and then +# writing to it cannot work: 0400 is read-only, to its owner as much as anyone. +sudo -u hbit install -m 0600 /dev/null /etc/hbit/wallet-passphrase sudo -u hbit tee /etc/hbit/wallet-passphrase >/dev/null <<< 'your passphrase' +sudo chmod 0400 /etc/hbit/wallet-passphrase sudo cp deploy/systemd/*.service /etc/systemd/system/ sudo systemctl daemon-reload @@ -153,7 +195,12 @@ every environment value. Open the pool port. Keep the node's RPC closed. +**Allow SSH before you enable ufw.** Its default incoming policy is deny, so +enabling it with no SSH rule locks you out of the machine you are configuring, +and on a VPS that means a console session or a rebuild. + ```bash +sudo ufw allow OpenSSH # or 22/tcp - do this FIRST sudo ufw allow 9777/tcp # miners sudo ufw allow 3337/tcp # chain p2p sudo ufw deny 8080/tcp # node RPC: never from outside @@ -183,12 +230,24 @@ Healthy log, roughly every settle interval: ``` [settle] holding back N unit(s) of block income that is not yet buried 16 deep [settle] submitted payout tx paying N miner(s) U units; the node holds it -[reorg] our block N orphaned (chain holds ) +[reorg?] the chain is showing at height N where our block stands. ... +[reorg] our block N orphaned (chain holds , buried 16 deep) ``` Orphans are normal: it means the pool noticed one of its blocks losing a race and did not pay out on income that no longer exists. +The two reorg lines are one event at two levels of certainty. `[reorg?]` is +provisional: a competing hash is showing at a height where our block stands, +which at shallow depth is usually a one-block fork that flips back. Nothing is +decided by it and no money moves on it. `[reorg]` is final, and it is only +printed once the competing hash is buried 16 blocks deep - the same burial a +confirmation needs, because deciding an orphan on weaker evidence than a +confirmation is how a hold-back got released at zero confirmations. The block's +income stays held back for the whole of that wait, in both directions, which is +why a fork can briefly show a hold-back larger than the wallet's own settled +income. + A payout that stays pending across several cycles gets a warning naming the cause. The pool mines coinbase-only blocks unless the node has transactions to pack, so a payout confirms when a block includes it. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index d2c8b0ed..664a740a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -38,10 +38,28 @@ services: # internet, which is the single worst mistake available on this box. expose: - "8080" + environment: + # Not read by the node - it takes api_token from hacash.config.ini. This is + # here only so the healthcheck below can present the same token, and so + # compose refuses to start the stack when the operator has not set one. + HBIT_NODE_API_TOKEN: ${HBIT_NODE_API_TOKEN:?set HBIT_NODE_API_TOKEN to the same value as api_token in node/hacash.config.ini} healthcheck: # Answering /query/latest is the honest test: the process being alive says # nothing about whether it can serve the pool. - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/query/latest"] + # + # The token is required. `require_api_token` in server/src/server/server.rs + # returns 401 for a request without it and makes NO exception for + # loopback, so an unauthenticated healthcheck would fail for ever, the + # pool's `service_healthy` condition would never be met, and the stack + # would sit there looking like a slow sync. + # + # CMD-SHELL and not CMD: the exec form does no variable expansion. The + # doubled $$ is compose's escape, so the container's shell sees $HBIT_... + test: + [ + "CMD-SHELL", + "curl -fsS -H \"x-api-token: $$HBIT_NODE_API_TOKEN\" http://127.0.0.1:8080/query/latest", + ] interval: 15s timeout: 5s retries: 20 @@ -95,6 +113,14 @@ services: # docker inspect and in any process listing inside the container; a file # mounted read only does not. HBIT_WALLET_PASSWORD_FILE: /run/secrets/hbit_wallet_passphrase + # The node's [server] api_token, which MUST match node/hacash.config.ini. + # The node refuses to serve its API at all on a non-loopback bind with an + # empty token, and this pool reaches it across the compose network, so + # without this pair the node never listens and the pool never starts. + # + # Set it on the host and let compose pass it through, so the real value + # is not committed: export HBIT_NODE_API_TOKEN=... + HBIT_NODE_API_TOKEN: ${HBIT_NODE_API_TOKEN:?set HBIT_NODE_API_TOKEN to the same value as api_token in node/hacash.config.ini} secrets: - hbit_wallet_passphrase volumes: diff --git a/deploy/node/hacash.config.ini b/deploy/node/hacash.config.ini index e88b2ba9..9ee34fe7 100644 --- a/deploy/node/hacash.config.ini +++ b/deploy/node/hacash.config.ini @@ -3,11 +3,31 @@ ; Mining is OFF here. This node's job is to hold the chain and answer the pool's ; template and submit requests. The mining happens on other people's hardware. ; -; The RPC below binds 0.0.0.0, which is correct INSIDE a container and would be -; dangerous on a bare host. The compose file publishes no port for this service, -; so the RPC is reachable only from the pool container on the private network. -; If you ever run this node outside Docker, change bind to 127.0.0.1 and put the -; pool on the same machine, or you are handing the miner API to the internet. +; ============================================================================ +; SET `api_token` BELOW BEFORE THE FIRST START, to any long random string. +; ============================================================================ +; +; This node binds 0.0.0.0 because the pool runs in a different container and +; reaches it across the compose network. The node REFUSES to serve its API at +; all on a non-loopback bind with an empty token: see +; server/src/server/server.rs, which prints one line and returns from the listen +; task. The process then keeps running and keeps syncing the chain, looking +; healthy in every way except that nothing is listening on port 8080. The +; compose healthcheck never passes, the pool waits on it for ever, and the only +; symptom is a pool that never starts. +; +; An earlier version of this file said 0.0.0.0 "is correct INSIDE a container" +; and set no token. That was wrong: it is not correct anywhere, and this whole +; deployment could not come up. +; +; The pool sends this token on every request. Give it to the pool through the +; environment variable HBIT_NODE_API_TOKEN - never as a command-line argument, +; which is visible in `ps` and in `docker inspect` to anyone on the box. +; +; The token is not a substitute for the network boundary. The compose file +; still publishes no port for this service, so the API is reachable only from +; the pool container; the token is what stops anything else that lands on that +; network from driving the miner API. [node] listen = 3337 @@ -34,6 +54,9 @@ fast_sync = false enable = true listen = 8080 bind = 0.0.0.0 +; Replace with a long random string, and give the SAME value to the pool as +; HBIT_NODE_API_TOKEN. Leave it empty and this node will not serve at all. +api_token = CHANGE_ME_TO_A_LONG_RANDOM_STRING [miner] ; ============================================================================ diff --git a/deploy/systemd/hacash-node.service b/deploy/systemd/hacash-node.service index 99e5c957..c7756d6b 100644 --- a/deploy/systemd/hacash-node.service +++ b/deploy/systemd/hacash-node.service @@ -1,6 +1,7 @@ # Hacash fullnode, as the always-on half of an HBIT pool box. # -# Install to /etc/systemd/system/hacash-node.service (deploy/install.sh does it). +# Install to /etc/systemd/system/hacash-node.service. deploy/README.md, section +# "systemd", is the command list that puts every file where this unit expects it. # Every path here is absolute on purpose: a service manager gives a process no # terminal, no login shell and no working directory of its own. # diff --git a/deploy/systemd/hbit-pool.service b/deploy/systemd/hbit-pool.service index 02707ee2..85808098 100644 --- a/deploy/systemd/hbit-pool.service +++ b/deploy/systemd/hbit-pool.service @@ -1,6 +1,7 @@ # HBIT mining pool: serves work to other people's miners and pays them. # -# Install to /etc/systemd/system/hbit-pool.service (deploy/install.sh does it). +# Install to /etc/systemd/system/hbit-pool.service. deploy/README.md, section +# "systemd", is the command list that puts every file where this unit expects it. # # THIS SERVICE HOLDS A WALLET THAT OWES REAL MONEY TO REAL PEOPLE. Read the two # rules before you edit it: @@ -43,7 +44,7 @@ ExecStartPre=/opt/hbit/bin/hbit-wait-for-node.sh http://127.0.0.1:8080 3600 # node | wallet file | listen | share_bits | chain # 0.0.0.0:9777 is the public face of the pool and the ONLY port that belongs on # the internet. See the firewall section of POOL-OPERATOR.md. -ExecStart=/opt/hbit/bin/hbit-pool-server http://127.0.0.1:8080 /var/lib/hbit/pool/pool-wallet.key 0.0.0.0:9777 24 mainnet +ExecStart=/opt/hbit/bin/hbit-pool-server http://127.0.0.1:8080 /var/lib/hbit/pool/pool-wallet.key 0.0.0.0:9777 20 mainnet Restart=always RestartSec=30s # Must be larger than the ExecStartPre bound above, or systemd would kill the diff --git a/docs/MINING-AMD.md b/docs/MINING-AMD.md index 7f5c108d..5e73222b 100644 --- a/docs/MINING-AMD.md +++ b/docs/MINING-AMD.md @@ -27,8 +27,8 @@ chmod +x scripts/mining-amd/*.sh **End users (GitHub Releases):** -- **`hacash-miner-full-windows-x64*.zip`** — clean PC: fullnode + miners + panel → run `SETUP.bat` -- **`hacash-miner-only-windows-x64*.zip`** — you already have fullnode → run `SETUP-MINER.bat` +- **`hacash-miner-full-windows-x64*.zip`** for a clean PC: fullnode + miners + panel → run `SETUP.bat` +- **`hacash-miner-only-windows-x64*.zip`** when you already have a fullnode: run `SETUP-MINER.bat` **Maintainers:** push a new SemVer tag such as `vX.Y.Z`, or run **Actions → Release (Windows + Linux miners) → Run workflow** for artifacts without publishing a tagged release. @@ -47,7 +47,8 @@ chmod +x scripts/mining-amd/*.sh ``` 5. Configure the workers (or use the panel): - HAC `poworker.config.ini`: set OpenCL `platform_id` / `device_ids` - - HACD `diaworker.config.ini`: set CPU `supervene`; GPU keys stay disabled + - HACD `diaworker.config.ini`: leave `supervene = 0` to fit the machine + (every logical CPU but two); GPU keys stay disabled 6. Run fullnode (`hacash.exe`) with RPC enabled (`[server] enable = true`). 7. Start mining: ```bat @@ -76,7 +77,9 @@ reward = YOUR_HACD_PRIVAKEY_3x The HACD reward value is a private key that starts with `3`. Keep it secret and never paste it into Fleet peers, logs or support messages. HACD mining is CPU/fullnode-only. In `diaworker.config.ini`, keep -`use_opencl = false` and use `supervene` to select CPU threads. +`use_opencl = false`. Leave `supervene = 0` and the worker takes every logical +CPU but two, which is measured to be roughly 20x one thread on a 32-thread CPU; +set a number only to take less. ## HAC GPU section reference @@ -107,9 +110,9 @@ These are generic starting candidates. Runtime device limits always win, and Aut | `amd_performance` | 2048 | 96 | Performance candidate for older RX architectures | | `amd_max` | 4096 | 128 | Aggressive generic candidate; use only through Auto Tune | -**RX 9070 XT / gfx1201:** validated hard ranges are `work_groups 32–64` and `unit_size 32–64`. Do not copy the generic 2048/4096 values into an RDNA4 config. First-run detection maps `gfx1201` to the RX 9070 XT preset before mining starts. +**RX 9070 XT / gfx1201:** validated hard ranges are `work_groups 32 to 64` and `unit_size 32 to 64`. Do not copy the generic 2048/4096 values into an RDNA4 config. First-run detection maps `gfx1201` to the RX 9070 XT preset before mining starts. -Auto Tune is fail-closed for more than one selected GPU because one INI currently stores one shared WG/US pair. Use one `poworker` instance/config per GPU—especially for heterogeneous cards—and aggregate them with Miner Fleet. +Auto Tune is fail-closed for more than one selected GPU because one INI currently stores one shared WG/US pair. Use one `poworker` instance/config per GPU, especially for heterogeneous cards, and aggregate them with Miner Fleet. ### Cost-aware mining (`[efficiency]`) @@ -136,14 +139,14 @@ When `max_temp_c > 0`, a valid `amd-smi`/`rocm-smi`, `nvidia-smi` or explicit `t Hashrate units are selected automatically (`H/s`, `kH/s` or `MH/s`). Watts, kH/J and daily values are estimates unless an external telemetry source is supplied. Scripts: -- `CONFIGURE-MINING.bat` — pick CPU + GPU -- `BENCHMARK-AMD.bat` — run the benchmark helper +- `CONFIGURE-MINING.bat`: pick CPU + GPU +- `BENCHMARK-AMD.bat`: run the benchmark helper Use the panel Auto Tune for GPU WG/US values. `TUNE-AMD-EFFICIENCY.bat` only adjusts basic CPU `supervene` settings. ### HAC hybrid mining -With `cpu_assist = true`, the GPU runs OpenCL and Ryzen threads mine on CPU **in parallel** — better total hashrate than GPU-only. +With `cpu_assist = true`, the GPU runs OpenCL and Ryzen threads mine on CPU **in parallel**, better total hashrate than GPU-only. ## AMD optimizations @@ -153,7 +156,8 @@ Every gfx1201 startup runs a Groestl integrity self-test, and every production G ## HAC CPU-only fallback (Ryzen, no GPU) -Build without `ocl` or set `use_opencl = false` and increase `supervene` to your core count: +Build without `ocl` or set `use_opencl = false`. For HACD leave `supervene = 0` +and the worker takes every logical CPU but two; `poworker` still needs a number: ```ini supervene = 8 diff --git a/docs/POOL-OPERATOR.md b/docs/POOL-OPERATOR.md index ab01a4c4..a712ab29 100644 --- a/docs/POOL-OPERATOR.md +++ b/docs/POOL-OPERATOR.md @@ -62,10 +62,13 @@ export HBIT_WALLET_PASSWORD='a long passphrase you have written down' ``` It refuses to start, with an explanation and a `What to do:` line, if the node -is not answering, if the chain argument does not match that node, if the listen +is not answering, if the node is not on the chain you named, if its tip is more +than an hour old, if the chain argument does not match that node, if the listen address is wrong or its port is taken, if `share_bits` or `settle_secs` is not a number in range, or if another copy is already running on the same wallet. -Nothing is mined and nothing is paid when it refuses. +Nothing is mined and nothing is paid when it refuses. What it cannot tell you is +whether the node has finished syncing: nothing in the node's API says so, and +section 4 is what to do about that. **What a good start looks like.** Just before `listening on` it prints a readback. Check every line of it: @@ -366,13 +369,47 @@ every few seconds spends the reserve for nothing, and `0` would leave the settlement thread spinning against the node with no pause at all. Leave it out unless you have a reason. -### The node has to be there, and it has to be yours - -Before anything else the pool asks the node for its current block. If nothing -answers it refuses, naming the URL it tried and where the port comes from (the -`[server] listen` value in the node's `hacash.config.ini`, 8080 in the config -this package ships). A node that is still syncing, or that would not hand over a -block template, is refused the same way. +### The node has to be there, it has to be yours, and it has to be at the tip + +After its own arguments, passphrase and accounting file have passed, the pool +asks the node for its current block. If nothing answers it refuses, naming the +URL it tried and where the port comes from (the `[server] listen` value in the +node's `hacash.config.ini`, 8080 in the config this package ships). Then it asks +three more things, and each one is its own refusal: + +- **Is this really mainnet?** With `chain` set to `mainnet` the pool reads block + 1's `prevhash`, which is the genesis hash by construction, and compares it + with the genesis hash compiled into this build. It reads block 1 and not block + 0 because the node does not serve block 0 at all. A chain that begins + somewhere else is refused with `this node is NOT on the chain this pool pays + out on`, and so is a node that will not answer for block 1: unknown is not + permission. This one is mainnet only, because a testnet genesis is whatever + the person who started that chain made it. +- **Is the tip fresh?** A tip whose own timestamp is more than **3600 seconds** + old is refused, saying how many minutes of silence that is and telling you to + wait for the node to reach the network tip. +- **Will the node hand over a block template?** One that answers but will not + give a template is refused too. + +**The pool cannot detect a syncing node as such, and does not claim to.** The +node's `/query/latest` answers with a height and a diamond number and nothing +else: no peer count, no best-known height, no sync flag. A node stalled part way +through a sync answers all three questions above with total confidence, and the +only one it eventually fails is the tip timestamp, once it has fallen an hour +behind. Watch the node's own log for the sync, and do not read a clean pool +start as proof that the node is at the network tip. + +Two of these questions keep being asked after the pool is up, and there they +halt it instead of refusing to start. The tip is re-checked on every template +cycle against a looser **7200 seconds**, and a failed write of the accounting +file when a block is found sets a halt of its own that no template change +clears. A halted pool credits no new share and plans no fresh payout, though +payouts already in flight keep resolving; it reports the reason on `/terms` as +`crediting_halt_reason`, and it tells connected miners to stop rather than let +them burn power for credit it will not give. The node halt lifts by itself the +moment the tip moves again; the accounting halt needs the disk fixed and the +pool restarted. Why the two bounds differ, and what each halt does and does not +cover, is [hbit-v2/MAINNET-SAFETY.md](hbit-v2/MAINNET-SAFETY.md). ### The listen address has to be usable @@ -485,7 +522,11 @@ finding the one you are looking at. |---------|-------|-----| | `REFUSING to run: another hbit-pool-server or hbit-pool-payout already holds ...` | Both settlers running at once | Stop `hbit-pool-server`, run the tool, restart the server. Do not delete the `.settle.lock` file: it frees nothing | | `REFUSING to start: no Hacash fullnode answered at ...` | Node not running, still starting, or wrong URL/port | Start and sync the node; use the `[server] listen` port from its `hacash.config.ini` (8080 here) | +| `REFUSING to start: this node is NOT on the chain this pool pays out on` | The node's chain begins at a different genesis than mainnet | Point the pool at a mainnet node, or pass the chain that node really runs as `testnet::` | +| `REFUSING to start: ... could not read block 1 from the node` | The node would not answer for block 1, so its chain cannot be identified | Let the node get past its first blocks, then start the pool again | +| `REFUSING to start: the node's tip is block N, stamped M minute(s) ago ...` | The tip is over 3600s old, so this node has probably stopped following the chain | Wait for the node to reach the network tip, then start the pool again | | `REFUSING to start: the node ... would not give a block template` | Node is up but not ready to be mined on | Let it finish syncing, then start the pool again | +| `[node] STOPPED crediting shares and STOPPED settling: ...` | A running pool's tip went 7200s without moving | Fix the node; crediting and settlement resume by themselves when the tip moves again | | `REFUSING to start: cannot listen on ...` | `` is not `:`, or the port is taken | Use `0.0.0.0:9777` or `127.0.0.1:9777`; if the form is right, something else holds that port | | `wallet file ... is encrypted but no passphrase is configured` | Passphrase missing from the environment | Set `HBIT_WALLET_PASSWORD` or `HBIT_WALLET_PASSWORD_FILE` | | `cannot decrypt wallet file ...` | Wrong passphrase, or a corrupted file | Use the backed-up passphrase; restore the file from backup | diff --git a/docs/hbit-v2/ARCHITECTURE.md b/docs/hbit-v2/ARCHITECTURE.md new file mode 100644 index 00000000..a7544cfc --- /dev/null +++ b/docs/hbit-v2/ARCHITECTURE.md @@ -0,0 +1,144 @@ +# HBIT pool: how a share becomes money + +This describes what the code does **today**, not what it should do. Where a +behaviour is a known defect it says so and does not soften it. Every claim here +was read out of the source; anything that could not be verified is marked as +such rather than guessed. + +Nothing in this directory documents a feature that does not exist. Several +documents the upgrade plan calls for are deliberately absent, and +[the index](README.md) says which and why. + +## Where state actually lives + +Three durable stores and one in-memory authority. + +**The in-memory authority** is one process-global `Mutex`, taken through +`plock`. Every route, the template thread, the confirm thread and the settlement +thread serialize on it. All accounting lives on that struct: the PPLNS window, +the immature hold-backs, the owed rows, the payout records, the pending payout +hashes and the replay set. + +**The accounting file** is `{wallet_file}.state.json`, written by +`atomic_write`: temp file, `sync_all()` when durable, rename, then a parent +directory fsync. The directory fsync is a real fsync on unix and a no-op on +every other platform, Windows included. So on Windows the strongest available +claim is "fsynced bytes, journalled rename", not "durable", and no part of this +system should be described as durable on that platform. + +**The wallet key file**, optionally encrypted with Argon2id and AES-256-GCM. + +**The settlement lock**, `{wallet_file}.settle.lock`, held with a real OS +exclusive advisory lock. The OS releases it when the holder dies, so a crash +cannot wedge payouts, and deleting the lock file does not free the lock. + +The chain is not one of these stores. It is queried, but nothing about who +earned what is ever written to it or derived from it. + +## The path, end to end + +1. **Template.** The pool reads the node's tip, then that block's intro, and + builds a template for `tip + 1`. The coinbase always pays the **pool's** + address, never a worker's. One template is cached per height and served + byte-identically to every miner. + +2. **Work.** `GET /query/miner/pending` returns that cached blob. It ignores + every query parameter, including `worker`. The `target_hash` it carries is + the pool's **share** target, so a pooled miner never sees the network target. + +3. **Submission.** `GET /submit/miner/success` with height, coinbase nonce, + block nonce and worker. The worker is whatever the query string says, as long + as it parses as a payable address. There is no TLS on this path and no header + is ever read. + +4. **Validation**, in four deliberate phases: + - *lock*: stale height, then the replay set keyed on + `(height, coinbase_nonce, block_nonce)`. That key has **no worker + component**, which is defect A9. + - *no lock*: rebuild the coinbase and the 89-byte header from the pool's own + template plus the submitted nonces, and hash that. A miner cannot get + credit for a header the pool did not build, and cannot redirect the reward. + Two comparisons follow: against the share target, and against the network + target. + - *lock*: halt gates, rate limits, replay insert, and then the one line that + turns hashing into money, `pplns.record(worker, at_ms)`. A found block also + pushes an `Immature` hold-back row. + - *no lock*: persist, then serialize and submit the block. + +5. **PPLNS.** A 4096-entry deque of `(worker, arrival_ms)`. Credit is + **milliseconds of residence** in the window, capped at a horizon. Shares + evicted from the window bank what they earned into time-stamped buckets that + expire one horizon later. **No share stores the target it was found against**, + so every share weighs the same regardless of the work it represents. + +6. **Maturity.** A background loop asks the node for each tracked height. + Confirmed means our hash is still there **and** the tip is at least 16 blocks + past it. The orphan arm is not depth-gated, which is defect A4. + +7. **Fees.** Each packed transaction's fee is read back off the node and folded + into the hold-back once. Any unreadable answer aborts the whole settlement + cycle rather than valuing the block at zero. + +8. **Settlement.** Value the wallet, subtract the hold-backs and a reserve, pay + old debts off the top, split the rest by **the live PPLNS credit at that + instant**, merge duplicate rows, chunk at 190 recipients, sign, persist the + hash and the exact signed bytes and the rows durably, and only then submit. + +9. **Paid.** Only a payout buried at least 6 blocks moves a unit from in-flight + to paid. + +## The structural fact the whole upgrade turns on + +Between step 4, where a block is found, and step 8, where money is split, +**nothing records who was mining**. The block creates exactly one row, the +hold-back, and no miner list. The income becomes anonymous wallet balance and is +split roughly eighty minutes later over whoever holds credit at that moment. + +A miner who connects after a block is found is paid from it. A miner who leaves +before settlement is paid nothing for the work that found it. That is defect A1, +and it is an economic defect rather than a bug: the code does exactly what it +was written to do. + +## What is already right, and must survive any change + +These were verified by reading, and each exists because something went wrong +once. Anything that breaks one of them is a regression however good it looks. + +- **The exclusive settlement lock**, a real OS lock rather than a file whose + existence means something. +- **Persist before submit.** The signed bytes reach disk and are fsynced before + the transaction is posted, and a failed write aborts the chunk. +- **Rebroadcast identical bytes, never re-sign.** The decision keys on whether + the bytes are still held, not on whether the node says it has the transaction. + A lost acknowledgement is Unresolved, never Rejected. +- **Burial before paid.** Nothing is called paid until the chain has buried it. +- **The 16 block coinbase maturity hold-back**, subtracted before anything is + split. +- **No floating point anywhere in the money path.** Two `f64` occurrences exist + in the whole crate and both are inside a test module. Payout splitting is + integer largest-remainder with a `u128` intermediate, and `overflow-checks` + is switched on for this package specifically. +- **The pool never trusts a worker-supplied header.** +- **A submission that beats the network target is exempt from every shedding + rule**: the halt, the per-height cap and the per-worker budget. It is a whole + block reward and it is never thrown away to save memory. +- **The four phase lock discipline**: the expensive hash happens with the pool + mutex released. + +## The halts + +Three, read through one accessor, `Pool::halt_reason()`, in this order. + +| halt | derived from | heals by itself | +|---|---|---| +| `accounting_halt` | a durable write that failed | **no** | +| `node_halt` | the tip's own timestamp, every template cycle | yes | +| `share_halt` | the live difficulty, every template change | yes | + +A halt stops two things: crediting new shares, and planning fresh payouts. It +deliberately does **not** stop the resolution of payouts already in flight, +which is money owed to named miners and must still reach them, and it does not +stop a found block being accepted, which is the operator's own reward. + +One accessor rather than three checks, so a fourth call site cannot be added +that consults only one of them. diff --git a/docs/hbit-v2/MAINNET-SAFETY.md b/docs/hbit-v2/MAINNET-SAFETY.md new file mode 100644 index 00000000..99ee45ec --- /dev/null +++ b/docs/hbit-v2/MAINNET-SAFETY.md @@ -0,0 +1,153 @@ +# What this pool refuses to do + +Fail-closed behaviour, as implemented. Every rule below is in the code and has a +test that fails if the rule is removed. Rules that are planned but not written +are in the last section, named as absent. + +## It refuses to start against a node it cannot identify + +Every other startup check asks the node about its own tip and verifies the +answer is self-consistent: the tip's difficulty really does follow from the +block before it. A node on a different chain passes all of them effortlessly, +because it is perfectly consistent with itself. + +So the first question asked is the only one another chain cannot answer the same +way: **where does this chain begin**. The expected hash comes from +`mint::genesis`, through `hbit_pool::mainnet_genesis_hex()`, and is never written +as a literal in the pool. A literal typed from memory into a second file is how +software ends up verifying itself against its own mistake. + +It is read from **block 1**, whose `prevhash` is the genesis hash by +construction. Not from block 0: this node does not serve the genesis block at +all. `?height=0` answers "cannot find block", and so does a lookup by its hash, +because the handler defaults `height` to 0 and cannot tell zero from "no height +given". The first version asked for block 0 and had seven passing tests, all +against a stub that answered a shape the real node never produces. Pointing the +pool at a live mainnet node is what found it. + +A node that will not answer for block 1 is refused too. Unknown is not +permission. + +This applies on mainnet only. A testnet genesis is whatever the person who +started that chain made it, so there is nothing to verify against and pretending +otherwise would refuse every legitimate testnet. + +## It refuses to start against a node that stopped following the chain + +Right chain, wrong place on it. A node stalled part-way through a sync answers +everything confidently. A pool that starts against one mines a fork nobody else +is on, watches its own blocks get buried sixteen deep **there**, releases the +hold-back on that evidence and signs real payouts against income the real chain +never credited. Miners burn real power for shares that can never mature. + +The evidence available is the tip's own timestamp. At startup a tip older than +**3600 seconds** is refused. + +That bound is measured rather than assumed. Over the 200 blocks ending at height +771596 the median gap was 212s, the mean 320s against a 300s target, and the +largest 2013s. One gap in two hundred exceeded 1800s, which was the original +bound - and the first live restart of this pool hit exactly that: a healthy node, +a 32 minute gap, and a refusal to start. A one-in-two-hundred chance of telling +an operator their node is broken when it is not becomes a restart loop under +systemd. + +## It halts a running pool whose node goes quiet + +The same question asked continuously, from the tip's timestamp on every template +cycle, with a much looser bound: **7200 seconds**. + +The two bounds differ on purpose. Mainnet aims at one block per 300 seconds and +block arrivals are Poisson, so long gaps happen by chance: + +| bound | targets | exceeded in 200 measured mainnet blocks | +|---|---|---| +| 1800s | 6 | 1 of 200 - too tight, and it fired on a healthy node | +| 3600s | 12 | 0 of 200 | +| 7200s | 24 | 0 of 200 | + +A false halt on a running pool stops crediting miners who are hashing a template +that is still perfectly valid, so it takes the looser bound. Neither bound can +tell "the chain is quiet" from "this node is stuck": from one node those look +identical, and the threshold is the whole of the answer available. + +This check is computed **outside** the "the template changed" branch. The entire +signature of a node that has stopped following the chain is that nothing +changes: it keeps answering, keeps returning the same height, and looks calm. A +check that only runs on a change never runs again. + +The template's own timestamp cannot serve here. It is derived from the wall +clock, so on a node stuck a week ago it still reads as now. The tip's timestamp +is the chain's own last heartbeat, and it is carried on the template as +`prev_timestamp` for exactly this reason. + +A tip dated in the future counts as zero seconds behind. That is clock skew, and +no pool should stop paying anyone over an ntp correction. + +## It never reports an unreadable answer as a lost block + +`/submit/block` used to be read as a bool: `ret:0` or everything else. A timeout, +a proxy's HTML error page and an empty body all fell into "everything else" and +were announced to the operator as a whole block reward lost, when the node may +never have seen the bytes. + +Three states now: + +| answer | verdict | action | +|---|---|---| +| `ret:0` | Queued | tracked for confirmation | +| `ret` non-zero | Refused | really lost, said plainly | +| timeout, HTML, empty, JSON with no `ret` | Unresolved | retried | + +Five attempts over 7.5 seconds. A refusal is **not** retried, because identical +bytes earn an identical answer. A refusal that arrives **after** an unresolved +attempt is reported as unresolved rather than as a loss: the likeliest reason a +node refuses a block it did not refuse a second ago is that it already holds it, +and crying loss there teaches an operator to distrust the line that is a real +loss. + +## It stops moving money when it cannot write its own books + +The settlement path always honoured a failed durable write. The block path threw +the answer away, and the block path is the one place where the write carries +something the pool cannot reconstruct: the hold-back that keeps the next +settlement from distributing a whole subsidy at zero confirmations. + +Now a failed write on the block path sets `accounting_halt`, which no template +change can clear. + +**The block is still submitted.** It is irreplaceable, the chain does not care +what the pool managed to write to disk, and refusing to submit would turn a +bookkeeping failure into a certain loss of the entire reward. What stops is the +movement of money. + +### The limit of this, stated plainly + +`accounting_halt` is not persisted. If the pool is restarted before the disk is +fixed, it reads a state file that never learned about the block, and it will +distribute that block's income at zero confirmations. A second write to the same +full disk would fail too, so this is not closed by a patch; it closes when the +ledger itself becomes durable. The operator message says so in as many words. + +## What is NOT implemented + +Named here so nobody reads the sections above as a complete safety story. + +- **A tip freshness bound that cannot be fooled.** One node cannot tell "the chain is quiet" from "this node is stuck": both look like an old tip. The startup bound is 3600s and the running bound 7200s, measured against 200 real mainnet blocks whose largest gap was 2013s. A node stalled for less than an hour is accepted. +- **Peer count, best-known-peer height, sync-complete and validation mode.** The + node's `/query/latest` returns only `height` and `diamond`. Real readiness + needs a new node endpoint, which is a change to the node and not to the pool. +- **A pool state machine.** There are three halts and one accessor, which is the + embryo of one, but there is no explicit STARTING / SYNCING / READY / + DEGRADED / RECOVERY state exposed anywhere. +- **Frozen block entitlements, as a payment rule.** The pool now RECORDS who + earned each block, at the instant it is found and in the same durable + snapshot, and reports what the two models would pay when they differ. It still + pays the old way: over whoever holds PPLNS credit at settlement time. Stage + two, which makes the recorded answer the paying one, needs real blocks to + compare first. See [ARCHITECTURE.md](ARCHITECTURE.md). +- **Authenticated jobs.** The miner can now reach a pool over https, which + removes the position an attacker needs. The pool still credits a share to + whatever payout address the query string names, so on plain HTTP anyone on the + path can resend a miner's work under their own address. +- **Signer isolation.** The wallet key lives in the same process that serves the + public listener. diff --git a/docs/hbit-v2/README.md b/docs/hbit-v2/README.md new file mode 100644 index 00000000..5d56747c --- /dev/null +++ b/docs/hbit-v2/README.md @@ -0,0 +1,45 @@ +# HBIT v2 documentation + +## What is here + +| document | describes | +|---|---| +| [ARCHITECTURE.md](ARCHITECTURE.md) | how a share becomes money today, including the defects | +| [MAINNET-SAFETY.md](MAINNET-SAFETY.md) | what the pool refuses to do, as implemented and tested | + +## What is deliberately not here + +The upgrade plan calls for a further eight documents. They are absent on +purpose, because the machinery they would describe has not been written, and a +document that describes an unimplemented mechanism is worse than no document: an +operator reads it, believes the protection exists, and runs a mainnet pool on +that belief. + +| document | blocked on | +|---|---| +| ACCOUNTING.md | frozen block entitlements and persistent per-miner balances | +| MIGRATION.md | a ledger to migrate to | +| RECOVERY.md | a recovery-required state that the pool can actually enter | +| BACKUP-RESTORE.md | the backup and verify commands | +| PROTOCOL-HBIT1.md | the versioned job protocol, job tokens and pool identity | +| THREAT-MODEL.md | can be written now, but is worth writing once the protocol above exists, or it documents a threat surface about to change | +| OPERATOR-RUNBOOK.md | the deployment fixes; the current systemd path does not start | +| MAINNET-VERIFICATION.md | there is nothing yet verified on mainnet to report | + +Each will be written when the thing it describes exists and has a test that +fails when it is removed. + +## What has and has not been proven + +Stated at the level of evidence, not confidence. + +**Unit and fixture tested.** Everything in MAINNET-SAFETY.md. Each rule has at +least one test that calls the real function rather than re-implementing it, and +each was proven by reverting the fix and watching the test fail. + +**Not tested against a live mainnet node.** No part of this has run against the +real chain. The stub nodes in the test suite answer over real HTTP and speak the +node's real response shapes, which is not the same thing. + +**Never exercised on mainnet.** No block has been submitted, no payout signed +and no transaction broadcast as part of this work. diff --git a/hbit-pool/Cargo.toml b/hbit-pool/Cargo.toml index fc781104..dcc88367 100644 --- a/hbit-pool/Cargo.toml +++ b/hbit-pool/Cargo.toml @@ -1,6 +1,30 @@ [package] name = "hbit-pool" -version = "0.1.0" +# Versioned and released INDEPENDENTLY of the miner, on `pool-v*` tags, because +# they are different products for different people: the miner is a Windows +# desktop program an individual runs, the pool is Linux server software an +# operator runs on a VPS beside their own node. An operator holding other +# people's money should not wait for a miner release to get a fix, and a miner +# should not be re-downloaded because a pool changed. +# +# 0.2.0 was the first release the pool made on its own: the accounting file +# gained a schema, a found block began recording who earned it, and several +# payout rules changed. Nothing an older build wrote became unreadable, but what +# that build MEANS by the file is different, which is why it was not 0.1.1. +# +# 0.2.1 is a patch: a halted pool now tells miners to stop, in the words their +# existing pause already keys on, so no rig keeps hashing for credit that will +# never come. No file format and no payout rule moved. +# +# 0.2.2 is a patch too, and the reason to take it is that 0.2.1 could pay a +# miner twice. A node refusal reached with the transaction already on chain was +# read as "that payout is lost", which put its rows back on the owed ledger. It +# also stops losing banked credit by worker id on restart, stops the per-worker +# rate limiter failing open in silence, and releases a rig parked on a +# same-height reorg instead of leaving it hashing an abandoned parent. The +# accounting file is unchanged in both directions, so 0.2.1 and 0.2.2 read each +# other's state; only /stats gains a field. +version = "0.2.2" edition = "2024" description = "HBIT: the Hacash mining pool this project ships. Share accounting, PPLNS payout splitting and on-chain settlement, plus the feasibility spikes it grew out of." diff --git a/hbit-pool/examples/fee_probe.rs b/hbit-pool/examples/fee_probe.rs index e4136b7d..63e3e141 100644 --- a/hbit-pool/examples/fee_probe.rs +++ b/hbit-pool/examples/fee_probe.rs @@ -11,7 +11,7 @@ //! //! usage: fee_probe -use hbit_pool::{BlockFees, block_fees, http_client}; +use hbit_pool::{BlockFees, block_fees, find_u64, get_json, http_client}; fn main() { let a: Vec = std::env::args().collect(); @@ -24,7 +24,19 @@ fn main() { let hash = a[3].to_lowercase(); let client = http_client(); - match block_fees(&client, &node, height, &hash) { + // The probe answers with the settlement's own judgement, and that judgement + // now reads a missing block against the node's tip: above it, waiting; + // at or below it, a refusal. So the probe needs the same tip the + // settlement would use, and a node that cannot even say its tip gets the + // same treatment the settlement gives it. + let Some(tip) = find_u64( + &get_json(&client, &format!("{node}/query/latest")), + "height", + ) else { + println!("Unknown: the node at {node} did not answer /query/latest"); + std::process::exit(1); + }; + match block_fees(&client, &node, height, &hash, tip) { BlockFees::Counted(u) => { println!("Counted({u}) units of 0.1 HAC"); } diff --git a/hbit-pool/examples/local_chain_watch.rs b/hbit-pool/examples/local_chain_watch.rs index 680a36eb..8edc29a0 100644 --- a/hbit-pool/examples/local_chain_watch.rs +++ b/hbit-pool/examples/local_chain_watch.rs @@ -156,7 +156,11 @@ fn print_milestones(ms: &[(u32, u64, u64)], want: u32) { } println!("\nfirst sighting of each bit level:"); for (bits, secs, hei) in ms { - let mark = if *bits >= want { " <- pool servable" } else { "" }; + let mark = if *bits >= want { + " <- pool servable" + } else { + "" + }; println!(" {bits:>2} bits at {secs:>6}s height {hei}{mark}"); } } diff --git a/hbit-pool/examples/rig_tx.rs b/hbit-pool/examples/rig_tx.rs index 35ce26e7..d74b32d4 100644 --- a/hbit-pool/examples/rig_tx.rs +++ b/hbit-pool/examples/rig_tx.rs @@ -60,10 +60,9 @@ fn main() { // Distinct timestamps make the hashes differ. They go BACKWARDS: // the node refuses a transaction stamped later than its own // clock, so counting up rejects everything after the first. - let mut tx = - TransactionType2::new_by(main.clone(), fee.clone(), base_ts.saturating_sub(i)); + let mut tx = TransactionType2::new_by(main, fee.clone(), base_ts.saturating_sub(i)); let mut act = HacToTrs::new(); - act.to = AddrOrPtr::from_addr(to.clone()); + act.to = AddrOrPtr::from_addr(to); act.hacash = amt.clone(); tx.push_action(Box::new(act)).expect("push action"); tx.fill_sign(&acc).expect("fill_sign"); diff --git a/hbit-pool/examples/testnet_rig_plan.rs b/hbit-pool/examples/testnet_rig_plan.rs index d317797c..4c3533aa 100644 --- a/hbit-pool/examples/testnet_rig_plan.rs +++ b/hbit-pool/examples/testnet_rig_plan.rs @@ -146,9 +146,14 @@ fn main() { println!("== HBIT local-chain rig plan =="); println!("hashrate = {mhs} MH/s (x16rs repeat=1)"); - println!("difficulty_adjust_blocks = {adjust_blocks} -> ASERT anchors at height {}", p.asert_height); + println!( + "difficulty_adjust_blocks = {adjust_blocks} -> ASERT anchors at height {}", + p.asert_height + ); println!("anchor difficulty = {anchor_bits} leading zero bits (fixed constant 0xe9cfffff)"); - println!("pool needs >= {POOL_MIN_NETWORK_BITS} network bits (share_bits 18 + share cost 16)"); + println!( + "pool needs >= {POOL_MIN_NETWORK_BITS} network bits (share_bits 18 + share cost 16)" + ); println!("simulation window = {}\n", hms(max_secs)); // Equilibrium: a block at B bits takes 2^B/hashrate seconds, so the chain @@ -201,7 +206,10 @@ fn report(tt: u64, eq: f64, o: &Outcome) { println!("each_block_target_time = {tt}s (equilibrium ~{eq:.2} bits)"); match (o.reach_secs, o.reach_height) { (Some(s), Some(h)) => { - println!("reaches {POOL_MIN_NETWORK_BITS} bits after {} of mining, at height {h}", hms(s)); + println!( + "reaches {POOL_MIN_NETWORK_BITS} bits after {} of mining, at height {h}", + hms(s) + ); println!( "stays in the {POOL_MIN_NETWORK_BITS}..{BAND_MAX_BITS} band for {} of the simulated window", hms(o.band_secs) diff --git a/hbit-pool/src/difficulty.rs b/hbit-pool/src/difficulty.rs index c55910fb..3bf59ec3 100644 --- a/hbit-pool/src/difficulty.rs +++ b/hbit-pool/src/difficulty.rs @@ -2,7 +2,7 @@ //! pool can build templates the node accepts at REAL (mainnet) heights. //! //! This mirrors mint/src/check/difficulty_asert.rs exactly. Every detail below -//! is load-bearing — a value that is off by one means the node rejects the +//! is load-bearing - a value that is off by one means the node rejects the //! block: //! * the exponent uses i128 `/` (truncates TOWARD ZERO, not floor) //! * num_shifts uses an arithmetic shift (floor) and the fraction is derived @@ -48,6 +48,19 @@ impl ChainParams { bootstrap_max: 0, } } + /// Is this the one chain whose genesis block is a fixed, known quantity? + /// + /// Derived from `mainnet()` rather than restating its numbers, so a change + /// there cannot leave a second copy behind saying something else. A testnet + /// genesis depends on whoever started that chain, so nothing can be verified + /// against it and the caller must not pretend otherwise. + pub fn is_mainnet(&self) -> bool { + let m = Self::mainnet(); + self.asert_height == m.asert_height + && self.target_time == m.target_time + && self.bootstrap_max == m.bootstrap_max + } + /// Non-mainnet: ASERT anchors at window+2 and heights <= window+1 bootstrap. pub fn testnet(adjust_blocks: u64, target_time: u64) -> Self { Self { @@ -114,7 +127,7 @@ pub fn next_difficulty( assert!( height > p.asert_height, "height {height} is in the pre-ASERT (legacy/LWMA) range, which this \ - off-node builder does not implement — a pool only mines at the tip" + off-node builder does not implement - a pool only mines at the tip" ); let time_delta = timestamp as i128 - anchor_time as i128; diff --git a/hbit-pool/src/lib.rs b/hbit-pool/src/lib.rs index 09f699b9..88f5bcf0 100644 --- a/hbit-pool/src/lib.rs +++ b/hbit-pool/src/lib.rs @@ -22,19 +22,90 @@ use serde_json::Value; use zeroize::Zeroizing; pub fn http_client() -> reqwest::blocking::Client { - reqwest::blocking::Client::builder() + http_client_with_token("") +} + +/// Environment variable carrying the node's `[server] api_token`. +/// +/// An environment variable and not a command-line argument, because an argument +/// is visible in `ps` and in `docker inspect` to every user on the box, and this +/// token is what stands between the LAN and an unauthenticated miner API. +pub const NODE_API_TOKEN_ENV: &str = "HBIT_NODE_API_TOKEN"; + +/// A client that presents `api_token` on every request to the node. +/// +/// The token rides on the CLIENT as a default header rather than on each call, +/// so no call site can forget it: there are more than twenty, spread over the +/// settlement path, the template loop and the manual payout tool, and one that +/// forgot would read a 401 as "the node is down" and stall the pool. +/// +/// An empty token adds no header at all, which is exactly the behaviour every +/// existing loopback deployment already has. +/// +/// This matters because the node refuses to serve its API at all when it is +/// bound to a non-loopback address with an empty token +/// (`server/src/server/server.rs`: it prints one line and returns, while the +/// process keeps running and syncing). A pool that talks to its node across a +/// container network or a private LAN therefore CANNOT work without this. +pub fn http_client_with_token(api_token: &str) -> reqwest::blocking::Client { + let mut builder = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(20)) - .build() - .expect("http client") + // reqwest's default builder detects a system proxy, which on this build + // means HTTP_PROXY and friends out of the environment. The node is the + // operator's own machine, usually on loopback, and this client carries + // /submit/block - a whole subsidy plus every packed fee - and + // /submit/transaction, which carries SIGNED payout bytes. An inherited + // variable, from a shell profile or a container image nobody wrote, + // would put a third party on that path silently. There is no deployment + // in which the pool should reach its own node through a proxy. + .no_proxy() + // Separate from the total timeout: a host that accepts and then says + // nothing would otherwise hold a template-loop or settlement thread for + // the whole 20 seconds before the pool learns it has no node. + .connect_timeout(std::time::Duration::from_secs(5)); + let token = api_token.trim(); + if !token.is_empty() { + let mut headers = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(token) { + // Same header name the miner sends (app/src/rpc_http.rs), because it + // is the same node API being authenticated to. + headers.insert("x-api-token", v); + builder = builder.default_headers(headers); + } else { + // A token with bytes a header cannot carry would silently become no + // token at all, and the pool would then read every 401 as a node + // outage for as long as it ran. + eprintln!( + "[node] the api_token in {NODE_API_TOKEN_ENV} contains characters that cannot \ + be sent in an HTTP header, so NO token is being sent. If your node binds a \ + non-loopback address it will refuse to answer, and this pool will report it \ + as down. Use a token of printable ASCII." + ); + } + } + builder.build().expect("http client") +} + +/// The transport-failure document `get_json` returns, built with a real JSON +/// encoder rather than by pasting the error into a string literal. +/// +/// Formatting it by hand meant any quote or backslash in the error text produced +/// a body that was not JSON at all, which then fell back to a bare +/// `Value::String` and lost the `http_error` key every classifier looks for. It +/// still failed safe, by accident rather than by design, and the accident stops +/// being safe the moment a classifier grows a bare-string branch. +fn transport_failure(e: &impl std::fmt::Display) -> Value { + serde_json::json!({ "http_error": e.to_string() }) } pub fn get_json(client: &reqwest::blocking::Client, url: &str) -> Value { - let text = client - .get(url) - .send() - .and_then(|r| r.text()) - .unwrap_or_else(|e| format!("{{\"http_error\":\"{e}\"}}")); - serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)) + match client.get(url).send() { + Ok(r) => match r.text() { + Ok(text) => serde_json::from_str(&text).unwrap_or(Value::String(text)), + Err(e) => transport_failure(&e), + }, + Err(e) => transport_failure(&e), + } } pub fn post_hex(client: &reqwest::blocking::Client, url: &str, body: &str) -> String { @@ -47,6 +118,26 @@ pub fn post_hex(client: &reqwest::blocking::Client, url: &str, body: &str) -> St .unwrap_or_else(|e| format!("http_error: {e}")) } +/// A key at the ROOT of a node answer, and nowhere else. +/// +/// The counterpart to [`find_value`], which searches the whole document. Money +/// fields read through the searching version answer the question "does this key +/// exist anywhere in here", which is a question a captive portal, a proxy error +/// page or an unrelated endpoint can also answer yes to. These ask where the +/// value actually is. +pub fn top_value<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + v.as_object()?.get(key) +} + +/// [`top_value`] as a number, keeping [`find_u64`]'s string coercion: the node +/// really does render some numeric fields as JSON strings. +pub fn top_u64(v: &Value, key: &str) -> Option { + top_value(v, key).and_then(|x| { + x.as_u64() + .or_else(|| x.as_str().and_then(|s| s.trim().parse().ok())) + }) +} + pub fn find_u64(v: &Value, key: &str) -> Option { find_value(v, key).and_then(|x| { x.as_u64() @@ -140,13 +231,39 @@ pub fn balance_answer(j: &Value) -> BalanceAnswer { if !j.is_object() { return BalanceAnswer::NoAnswer(excerpt(&j.to_string())); } + // A ROOT `ret`, and it must be there. This used to be + // `find_u64(j, "ret").is_some_and(|r| r != 0)`, which fell straight through + // when the answer carried no `ret` at all - and the fall-through was a + // whole-document search for `hacash`, so ANY json containing that key + // anywhere became a balance this pool would pay against. + // `{"list":[{"hacash":"999999:248"}]}` was a Reported balance: a captive + // portal, a misrouted service or a stale cache could hand the settlement a + // number the node never said, and the split is computed from it. + let Some(ret) = top_u64(j, "ret") else { + return BalanceAnswer::Refused(excerpt(&j.to_string())); + }; // The node answered, and its answer is "no": a bad address, too many // addresses, an unreadable state. There is no balance in it to pay on. - if find_u64(j, "ret").is_some_and(|r| r != 0) { + if ret != 0 { return BalanceAnswer::Refused(excerpt(&j.to_string())); } - match find_str(j, "hacash") { - Some(s) if !s.trim().is_empty() => BalanceAnswer::Reported(s), + // `list[0].hacash`, by its real path. The recursion reached the same place + // by accident, taking whichever nested `hacash` sorted first - serde_json's + // Map here is a BTreeMap, so "first" meant alphabetical key order and not + // document order. + let Some(rows) = top_value(j, "list").and_then(|v| v.as_array()) else { + return BalanceAnswer::Refused(excerpt(&j.to_string())); + }; + // Exactly one. Every caller asks about ONE address - the pool's own wallet - + // so a reply carrying several is not an answer to the question that was + // asked, and taking the first of them would be guessing which is the wallet. + // The node accepts up to 200 addresses in one query, so this is a real shape + // it can produce. + if rows.len() != 1 { + return BalanceAnswer::Refused(excerpt(&j.to_string())); + } + match rows[0].get("hacash").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => BalanceAnswer::Reported(s.to_string()), // ret=0 with no `hacash` is a shape this pool does not recognise. The // node always emits the field, so its absence means we are not talking // to one - never that the wallet is empty. @@ -314,7 +431,13 @@ pub enum BlockTxs { /// Split out from [`block_fees`] so the decision - price it, ignore it, or stop /// settling - is testable without a node. Fails SAFE: only an answer that really /// carries our block's transaction list is [`BlockTxs::Ours`]. -pub fn block_txs_of(j: &Value, our_hash_hex: &str) -> BlockTxs { +/// +/// `height` is the height that was asked about and `tip` is the node's own tip +/// as THIS cycle already proved it: they decide what a refusal means, and the +/// answer is different on each side of the tip. The tip is a parameter rather +/// than a fresh read so the verdict is judged against the same chain state the +/// rest of the cycle is using; a tip re-read here could have moved. +pub fn block_txs_of(j: &Value, our_hash_hex: &str, height: u64, tip: u64) -> BlockTxs { // get_json encodes a transport failure as {"http_error": "..."} and a // non-JSON body as a bare string. Neither is the node speaking. if !j.is_object() || j.get("http_error").is_some() { @@ -324,8 +447,28 @@ pub fn block_txs_of(j: &Value, our_hash_hex: &str) -> BlockTxs { return BlockTxs::Unknown(excerpt(&j.to_string())); }; if ret != 0 { - // The node is up and has no block at that height: ours was refused, or - // has not been inserted yet. Either way it has credited nothing. + // The node produced no block at that height. What that means depends on + // where the height stands against the node's own tip. + // + // Above the tip it is the ordinary case: our block was just found and + // the node has not inserted it yet, or it was refused. Nothing has been + // credited, so there is nothing to hold and waiting is right. + // + // AT or BELOW the tip it is not an answer at all. The node's own tip + // says it holds a block at this height, and it just failed to produce + // it: a block-store read failure, or a reorg between the tip read and + // this one. Our block may well be canonical there, in which case its + // fee income is already sitting in the wallet with nothing holding it + // back. Reading this as "no fees" is how that income got distributed at + // zero confirmations, so it refuses instead, and the cycle settles + // nothing until the node can say. + if height <= tip { + return BlockTxs::Unknown(format!( + "the node's tip is {tip} but it produced no block at height {height}: \ + {}", + excerpt(&j.to_string()) + )); + } return BlockTxs::NotOnChain; } let Some(hash) = find_str(j, "hash") else { @@ -383,12 +526,13 @@ pub fn block_fees( node: &str, height: u64, our_hash_hex: &str, + tip: u64, ) -> BlockFees { let j = get_json( client, &format!("{node}/query/block/intro?height={height}&tx_hash_list=true"), ); - let hashes = match block_txs_of(&j, our_hash_hex) { + let hashes = match block_txs_of(&j, our_hash_hex, height, tip) { BlockTxs::Ours(hs) => hs, BlockTxs::NotOnChain => return BlockFees::NotOnChain, BlockTxs::Unknown(why) => return BlockFees::Unknown(why), @@ -426,6 +570,10 @@ pub enum PayoutTxState { Buried(u64), /// The node definitively does not know this hash: it was rejected, never /// relayed, or dropped from the mempool. Settling again is the right move. + /// + /// Only [`NODE_TX_ABSENT`] earns this. A refusal the node reached with the + /// transaction already in its chain state is NOT this, and reading it as + /// this is what pays a miner twice. Gone, /// We could not reach the node, or could not understand its answer. This is /// NOT a resolution: treating it as one is exactly what opens a double-payout @@ -433,6 +581,15 @@ pub enum PayoutTxState { Unknown, } +/// The one refusal from `/query/transaction` that really means the node has +/// never heard of a hash: its mempool missed AND `state.tx_exist` missed. +/// `mint/src/api/transaction.rs` writes it verbatim. +/// +/// Matched WHOLE, never by prefix. "transaction not found in the block" opens +/// with these same three words and means the opposite: that answer is only +/// reached once `tx_exist` has already found the transaction on chain. +const NODE_TX_ABSENT: &str = "transaction not found"; + /// Classify a `/query/transaction?hash=...` response. Fails SAFE: anything that /// is not an unambiguous verdict from the node comes back as `Unknown`, and a /// shallow confirmation counts as still in flight. @@ -446,7 +603,23 @@ pub fn classify_payout_tx(j: &Value) -> PayoutTxState { return PayoutTxState::Unknown; }; if ret != 0 { - return PayoutTxState::Gone; // the node answered "transaction not found" + // A refusal is not automatically "I have never heard of this hash", and + // the difference is a second payment out of the operator's own wallet. + // The node's handler answers ret=1 in four places, and two of them are + // reached only AFTER `state.tx_exist` has already FOUND the transaction + // on chain: the block behind it would not load, or the block it decoded + // did not contain it. Both are evidence the payout IS mined. Read as + // Gone they run `GoneAction::Forget`, which hands the rows back to the + // owed ledger and pays those miners again next cycle. + // + // So only the exact absence answer is Gone. Every other refusal, and any + // wording this pool has never seen, is Unknown: the hash stays tracked + // and the cycle is skipped. That costs a delay instead of somebody's + // money, which is the only direction this is allowed to fail in. + return match top_value(j, "err").and_then(|v| v.as_str()) { + Some(e) if e.trim() == NODE_TX_ABSENT => PayoutTxState::Gone, + _ => PayoutTxState::Unknown, + }; } let is_pending = j .get("data") @@ -624,6 +797,84 @@ fn read_state_json(state_file: &str) -> Option { j.is_object().then_some(j) } +/// The accounting schema this build reads and writes. +/// +/// Bumped ONLY when a change would make an older reader lose or misread money: +/// a key that changes meaning, or a new key carrying money an older reader would +/// default away and therefore pay out a second time. Adding an OPTIONAL key that +/// every existing reader already defaults to the safe value does NOT bump it, +/// which is why `owed` and `fees_counted` were added without a bump. +/// +/// A file with no `schema` key is schema 1: every file written before the key +/// existed really is schema 1, and reads correctly under this build. +pub const STATE_SCHEMA: u64 = 1; + +/// What the accounting file at a path turns out to be. +pub enum StateFile { + /// No file there. A first run, or an operator who deliberately cleared it. + /// Starting with empty accounting is correct: there is nothing to lose. + Fresh, + /// A file this build understands. The parsed document is inside. + Readable(Box), + /// A file is present and this build must NOT run on it. The string says why, + /// in words an operator can act on. Beside a funded wallet this is the + /// difference between "pay the current window twice" and "stop and be told". + Unreadable(String), +} + +/// Decide whether the accounting file may be read, WITHOUT reading it into the +/// pool. Separated from loading so the money rule - refuse to run empty beside a +/// funded wallet - is one testable decision both the server and the manual tool +/// make the same way. +/// +/// Fails CLOSED. Anything short of "a JSON object this build's schema covers" is +/// `Unreadable`: a permission error, a non-UTF8 file, a truncated write, valid +/// JSON that is not an object (`null`, `[]`, `42`), or a schema from the future. +/// Every one of those used to leave the pool running with zero owed, zero paid, +/// zero in flight - the exact state that distributes the whole wallet to the +/// live window. +pub fn classify_state_file(state_file: &str) -> StateFile { + let bytes = match std::fs::read(state_file) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StateFile::Fresh, + Err(e) => { + return StateFile::Unreadable(format!( + "the accounting file {state_file} exists but could not be read ({e})" + )); + } + }; + let Ok(txt) = String::from_utf8(bytes) else { + return StateFile::Unreadable(format!( + "the accounting file {state_file} is not UTF-8 text; it is not a ledger this pool \ + wrote" + )); + }; + let j: Value = match serde_json::from_str(&txt) { + Ok(j) => j, + Err(e) => { + return StateFile::Unreadable(format!( + "the accounting file {state_file} is not valid JSON ({e}); a truncated or \ + partial write looks exactly like this" + )); + } + }; + if !j.is_object() { + return StateFile::Unreadable(format!( + "the accounting file {state_file} is JSON but not an object, so none of its money \ + fields can be read" + )); + } + let schema = j.get("schema").and_then(|v| v.as_u64()).unwrap_or(1); + if schema > STATE_SCHEMA { + return StateFile::Unreadable(format!( + "the accounting file {state_file} was written by a newer build (schema {schema}; \ + this build reads up to {STATE_SCHEMA}). Reading it with an older build could pay \ + out money the newer format tracks and this one cannot see" + )); + } + StateFile::Readable(Box::new(j)) +} + /// The shared pending-payout ledger. A missing or corrupt file reads as an empty /// ledger (the server rewrites that file wholesale and reports the corruption). pub fn load_pending_payout_txs(state_file: &str) -> Vec { @@ -740,10 +991,20 @@ pub fn load_pplns_credit(state_file: &str) -> Vec<(String, u64)> { let Some(j) = read_state_json(state_file) else { return Vec::new(); }; - let window = j - .get("window") - .and_then(|v| v.as_u64()) - .unwrap_or(PPLNS_WINDOW as u64) as usize; + // The window the file claims, but never a value this build will not settle + // on. `Pplns::new` clamps with `.max(1)`, so a file saying `"window": 0` + // restored a window of ONE SHARE and handed this tool's whole fresh split to + // whichever miner submitted last. Anything absent, zero, or larger than this + // build's own window reads as this build's window - which is the honest + // answer, because that is the window the shares in the file were accepted + // under as far as this binary can tell. + // + // Clamping DOWN is only safe because `Pplns::restore` now banks what it + // trims. Before that it silently dropped the overflow and the credit with it. + let window = match j.get("window").and_then(|v| v.as_u64()) { + Some(w) if w >= 1 && w <= PPLNS_WINDOW as u64 => w as usize, + _ => PPLNS_WINDOW, + }; // The horizon the SERVER was running, so the manual tool splits money the // same way the automatic settlement would have. let horizon = j @@ -790,10 +1051,11 @@ pub fn load_pplns_credit(state_file: &str) -> Vec<(String, u64)> { /// before shares were stamped), because there is nothing to anchor to and the /// fallback stamp one horizon back then weighs every share alike, as it must. pub fn credit_anchor_ms(j: &Value, now_ms: u64) -> u64 { - let newest_share = j - .get("order") - .and_then(|v| v.as_array()) - .and_then(|a| a.iter().filter_map(|x| x.as_array()?.get(1)?.as_u64()).max()); + let newest_share = j.get("order").and_then(|v| v.as_array()).and_then(|a| { + a.iter() + .filter_map(|x| x.as_array()?.get(1)?.as_u64()) + .max() + }); let newest_bank = j .get("banked") .and_then(|v| v.as_array()) @@ -911,6 +1173,41 @@ pub const PAYOUT_DUST_UNITS: u64 = 1; /// actions, so stay safely under it: a large payout is chunked, never rejected. pub const PAYOUT_CHUNK: usize = 190; +/// The network fee of one settlement transaction, in the same units of 0.1 HAC +/// the rest of the money path uses. +/// +/// [`chunk_tx_fee`] is `Amount::coin(1, 246)`, one step finer than +/// [`PAYOUT_UNIT`] = 247, so a chunk costs a TENTH of a unit. Kept as a +/// numerator over [`FEE_UNITS_PER_TENTH`] rather than as a rounded integer, +/// because rounding a tenth up to a whole unit would overstate a small +/// settlement's cost tenfold and rounding it down to zero would tell the pool +/// its payouts are free. +pub const CHUNK_FEE_TENTHS: u64 = 1; +/// Tenths of a unit in a unit. See [`CHUNK_FEE_TENTHS`]. +pub const FEE_UNITS_PER_TENTH: u64 = 10; + +/// How many settlement transactions `recipients` will be cut into. +pub fn chunks_needed(recipients: usize) -> u64 { + recipients.div_ceil(PAYOUT_CHUNK) as u64 +} + +/// The most recipients the fee reserve can actually fund, and what it costs. +/// +/// The reserve is subtracted ONCE, in [`distributable_units`], while the fee is +/// paid per transaction: a settlement large enough to be cut into more chunks +/// than the reserve covers signs transactions the wallet cannot fund, and the +/// node refuses the tail. At `SETTLE_RESERVE_UNITS` = 5 (0.5 HAC) and a tenth of +/// a unit per chunk that is 50 chunks, which is 9500 recipients - far away, and +/// nothing anywhere checked it, so the pool would have discovered it by having +/// payouts refused with no idea why. +/// +/// Returns `(fundable_recipients, chunks_the_reserve_funds)`. +pub fn reserve_funds_recipients(reserve_units: u64) -> (usize, u64) { + let chunks = reserve_units.saturating_mul(FEE_UNITS_PER_TENTH) / CHUNK_FEE_TENTHS; + let recipients = (chunks as usize).saturating_mul(PAYOUT_CHUNK); + (recipients, chunks) +} + /* --------------------------------------------------------------------------- * Per-worker settlement ledger. * @@ -1304,10 +1601,21 @@ pub fn deduct_owed(owed: &mut Vec<(String, u64)>, rows: &[(String, u64)]) { /// Owed rows are taken in order and partially where the balance runs out, so one /// large debt cannot starve while smaller ones keep being paid around it. What is /// not taken stays on the ledger for the next cycle. +/// +/// A row whose address this pool cannot pay is passed OVER rather than allocated +/// to. It used to take its full amount off the top of every cycle, and then the +/// chunk builder skipped it when it could not turn the address into an action - +/// so it never entered `rows`, `deduct_owed` never cleared it, and it was owed +/// again next cycle. That is not a stall, it is a permanent tax: every honest +/// miner was short by exactly that amount, every cycle, for ever, and if the +/// dead row's amount reached the distributable total nobody was paid at all. +/// The debt is not forgotten - it stays on the ledger, and `unpayable_owed` +/// names it so the operator hears it from a log rather than from the balance +/// quietly climbing. pub fn take_owed(owed: &[(String, u64)], distributable: u64) -> (Vec<(String, u64)>, u64) { let mut left = distributable; let mut rows: Vec<(String, u64)> = Vec::new(); - for (w, u) in owed { + for (w, u) in owed.iter().filter(|(w, _)| is_payout_address(w)) { if left == 0 { break; } @@ -1321,6 +1629,18 @@ pub fn take_owed(owed: &[(String, u64)], distributable: u64) -> (Vec<(String, u6 (rows, left) } +/// Debts the pool is holding for a named miner it cannot address, and how much. +/// +/// These no longer consume the distributable balance (see [`take_owed`]), so +/// nothing is stuck behind them - but nothing pays them either, and an operator +/// must not have to infer that from the wallet drifting upward. +pub fn unpayable_owed(owed: &[(String, u64)]) -> Vec<(String, u64)> { + owed.iter() + .filter(|(w, u)| *u > 0 && !is_payout_address(w)) + .cloned() + .collect() +} + /// Fold rows paying the same address into one action, keeping first-seen order. /// /// An owed row and a fresh share for the same miner would otherwise be two @@ -2388,18 +2708,18 @@ fn windows_verify_owner_only(path: &str, name: &str, sid: &str) -> std::io::Resu }; aces += 1; if !principal.eq_ignore_ascii_case(name) && !principal.eq_ignore_ascii_case(sid) { - if let Some(resolved) = windows_sid_of(principal) { - if WINDOWS_OS_PRINCIPAL_SIDS.contains(&resolved.as_str()) { - // Said out loud rather than passed over silently: the - // operator should know exactly who else can read the key. - eprintln!( - "[wallet] NOTE: {path} is also readable by `{principal}` ({resolved}). \ + if let Some(resolved) = windows_sid_of(principal) + && WINDOWS_OS_PRINCIPAL_SIDS.contains(&resolved.as_str()) + { + // Said out loud rather than passed over silently: the + // operator should know exactly who else can read the key. + eprintln!( + "[wallet] NOTE: {path} is also readable by `{principal}` ({resolved}). \ That is the operating system itself and cannot be excluded; anything \ able to act as it already controls this machine. No other account can \ read the file." - ); - continue; - } + ); + continue; } return Err(std::io::Error::other(format!( "{path} is still accessible to `{principal}`" @@ -2456,12 +2776,19 @@ pub struct Template { pub height: u64, pub prevhash: Hash, pub timestamp: u64, - /// Header `difficulty` field (u32) — must equal what the node recomputes. + /// Header `difficulty` field (u32) - must equal what the node recomputes. pub difficulty: u32, /// The exact PoW target for this block. NOT interchangeable with /// u32_to_hash(difficulty): on the from_big path it is more precise. pub target: [u8; 32], pub coinbase_addr: Address, + /// Timestamp of the block this one builds on, i.e. of the node's tip. + /// + /// NOT `timestamp` above, which is the stamp of the block being built and is + /// derived from the wall clock: on a node that stopped following the chain + /// it still reads as now, so it can never reveal that the node is stuck. + /// This one is the chain's own last heartbeat. + pub prev_timestamp: u64, /// The transactions the node packed for this height, empty when the node /// would not tell us. Behind an `Arc` because the pool clones a whole /// template on every single share submission while holding its global lock, @@ -2589,6 +2916,7 @@ pub fn fetch_template_pinned( difficulty: diff_num, target, coinbase_addr: coinbase, + prev_timestamp: prev_ts, // Callers that mine a block of their own choosing (the spike tools) want // exactly the transactions they pass in. `fetch_pool_template` is what // attaches the node's packed set for the pool. @@ -2762,7 +3090,7 @@ pub fn fetch_pool_template( ) -> Option<(Template, Option)> { let live = current.map(|t| StampPin { height: t.height, - prevhash: t.prevhash.clone(), + prevhash: t.prevhash, timestamp: t.timestamp, }); let pin = live.as_ref().or(pin); @@ -2792,6 +3120,82 @@ pub fn fetch_pool_template( /// the node's OWN tip from its stored data and compare against what it stored: /// an exact match is the only proof that the parameters in force here are the /// ones the node validates with. +/// How stale the node's tip may be at STARTUP before this pool refuses to run. +/// +/// MEASURED, not modelled. Over the 200 blocks ending at mainnet height 771596: +/// +/// ```text +/// median gap 212 s +/// mean gap 320 s (the target is 300) +/// 99th percentile 1740 s +/// largest gap 2013 s (33.5 minutes) +/// gaps over 1800 s 1 of 200 (0.5%) +/// gaps over 2700 s 0 of 200 +/// gaps over 3600 s 0 of 200 +/// ``` +/// +/// This was 1800, on a Poisson estimate that said six targets would be exceeded +/// about once a day. The real chain exceeds it once in two hundred blocks, and +/// the first live restart of this pool hit exactly that: a healthy node, a +/// 32-minute gap, and a refusal to start. An operator restarting has no reason +/// to accept a one-in-two-hundred chance of being told their node is broken when +/// it is not, and under systemd that refusal becomes a restart loop. +/// +/// A pool that starts against a node which really has stopped following the +/// chain mines a fork, sees its own blocks buried sixteen deep THERE, releases +/// the hold-back and signs real payouts against income the real chain never +/// paid. That is what this guards, and an hour still catches it long before the +/// running bound would. +/// +/// One node cannot distinguish "the chain is quiet" from "this node is stuck": +/// both look like an old tip that is not moving. The threshold is the whole of +/// the answer available here, so it is set from what the chain actually does. +pub const TIP_STALE_SECS_AT_START: u64 = 3_600; + +/// The same question asked of a pool that is already running and holding money. +/// +/// Twenty four targets. A healthy chain exceeds this by chance about once in +/// 10^11 blocks, which is never, and that is the point: a false halt here stops +/// crediting miners who are hashing a template that is still perfectly valid. +/// Slower to notice, and it will not punish anyone for the chain being quiet. +pub const TIP_STALE_SECS_WHILE_RUNNING: u64 = 7_200; + +/// Why the node's tip is too old to mine on, or `None` when it is fine. +/// +/// A tip dated in the future is clock skew rather than the future, so it counts +/// as zero seconds behind: this must never halt a pool over an ntp correction. +pub fn tip_too_old( + tip_height: u64, + tip_unix: u64, + now_unix: u64, + limit_secs: u64, +) -> Option { + let behind = now_unix.saturating_sub(tip_unix); + if behind <= limit_secs { + return None; + } + let mins = behind / 60; + let blocks = behind / 300; + Some(format!( + "the node's tip is block {tip_height}, stamped {mins} minute(s) ago, which is about \ + {blocks} mainnet block(s) of silence. Either the whole chain has stopped, or - far \ + more likely - this node has stopped following it. A pool cannot tell the difference \ + from one node, so it assumes the dangerous one: every template built on this tip \ + would be mined against a chain the network has already moved past" + )) +} + +/// The mainnet genesis block hash as lowercase hex, taken from the node's own +/// constant rather than restated here. +/// +/// This is the pool's only unforgeable statement about which chain it is on. It +/// is deliberately a function over `mint::genesis`, not a literal: a literal +/// typed from memory into a second file is exactly how a pool ends up verifying +/// itself against its own mistake. +pub fn mainnet_genesis_hex() -> String { + mint::genesis::genesis_block_hash().to_hex() +} + pub fn verify_chain_params( client: &reqwest::blocking::Client, base: &str, @@ -2801,8 +3205,63 @@ pub fn verify_chain_params( let Some(tip) = find_u64(&latest, "height") else { return Err("could not read the chain tip from the node".to_string()); }; + let intro = |h: u64| get_json(client, &format!("{base}/query/block/intro?height={h}")); + + // IDENTITY FIRST, before anything derived from the chain's own numbers. + // + // Every check below this point asks the node about its own tip and verifies + // the answer is self-consistent. A node on a different chain passes all of + // them effortlessly, because it is perfectly consistent with itself: its + // difficulty really does follow from its own previous block. The one + // question no other chain can answer the same way is where its chain began. + // + // Without this, a pool pointed at the wrong node mines a chain nobody else + // is on, watches its own blocks get buried 16 deep THERE, releases the + // hold-back and signs real payouts against income the real chain never + // credited. The wallet is real; the income is not. + // + // Read from BLOCK 1, not block 0. This node does not serve the genesis block + // at all: `/query/block/intro?height=0` answers "cannot find block", and so + // does a lookup by its hash, because `height` defaults to 0 in that handler + // and zero is indistinguishable from "no height given". Block 1's `prevhash` + // IS the genesis hash by construction, and it is served. + // + // That was found by pointing this pool at a real mainnet node. The first + // version asked for height 0 and was tested against a stub that answered it, + // so seven tests agreed with each other about a shape the real node never + // produces. + if params.is_mainnet() { + if tip < 1 { + return Err( + "this node has no blocks at all, so there is nothing to identify the chain by. \ + A mainnet node has 700000 or more; wait for it to sync" + .to_string(), + ); + } + let first = intro(1); + let Some(theirs) = find_str(&first, "prevhash") else { + return Err(format!( + "could not read block 1 from the node, so the chain it is running cannot be \ + identified, and this pool will not pay miners out of a wallet it cannot tie \ + to mainnet. The node answered: {first}" + )); + }; + let ours = mainnet_genesis_hex(); + if !theirs.eq_ignore_ascii_case(&ours) { + return Err(format!( + "this node is NOT on the chain this pool pays out on. Its chain begins at \ + {theirs}; mainnet begins at {ours}. Point the pool at a mainnet node, or pass \ + the chain the node is really running as `testnet::\ + `" + )); + } + } + if tip == 0 { - return Ok(()); // empty chain: the node has stored nothing to compare to + // Empty chain: nothing has been mined, so there is no tip to check the + // difficulty rule against. On mainnet the identity check above has + // already run, so this is no longer the blanket pass it used to be. + return Ok(()); } if tip > params.bootstrap_max && tip < params.asert_height { return Err(format!( @@ -2812,11 +3271,22 @@ pub fn verify_chain_params( params.asert_height )); } - let intro = |h: u64| get_json(client, &format!("{base}/query/block/intro?height={h}")); let b = intro(tip); let (Some(ts), Some(stored)) = (find_u64(&b, "timestamp"), find_u64(&b, "difficulty")) else { return Err(format!("could not read block {tip} from the node")); }; + // Right chain, wrong place on it. The identity check above proves only that + // the node knows what mainnet is, not that it is anywhere near the end of + // it, and a node stalled part-way through a sync answers every question so + // far with perfect confidence. This is the documented failure mode of this + // deployment: history sync finishes short of the tip and then ignores live + // blocks until the process is restarted. + if let Some(why) = tip_too_old(tip, ts, curtimes(), TIP_STALE_SECS_AT_START) { + return Err(format!( + "{why}. Wait for the node to reach the network tip and start the pool again" + )); + } + let prev_diff = if tip > 1 { match find_u64(&intro(tip - 1), "difficulty") { Some(d) => d as u32, @@ -2861,8 +3331,7 @@ pub fn coinbase_with_extranonce( tpl: &Template, extranonce: &[u8; 32], ) -> mint::TransactionCoinbase { - let mut cb = - mint::create_coinbase_tx(tpl.height, coinbase_message(), tpl.coinbase_addr.clone()); + let mut cb = mint::create_coinbase_tx(tpl.height, coinbase_message(), tpl.coinbase_addr); let en = Hash::from_hex(hex::encode(extranonce).as_bytes()).expect("extranonce"); cb.extend = mint::CoinbaseExtend::must(mint::CoinbaseExtendDataV1 { miner_nonce: en, @@ -2886,7 +3355,7 @@ fn build_intro(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) -> Bl version: Uint1::from(1), height: BlockHeight::from(tpl.height), timestamp: Timestamp::from(tpl.timestamp), - prevhash: tpl.prevhash.clone(), + prevhash: tpl.prevhash, mrklroot: calculate_mrkl_prelude_update(cb.hash_with_fee(), &tpl.txs.mrklrts), transaction_count: Uint4::from(tpl.txs.block_tx_count()), }, @@ -2903,7 +3372,7 @@ pub fn intro_bytes(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) - build_intro(tpl, cb, nonce).serialize() } -/// Hex of the serialized coinbase tx — the `coinbase_body` a worker receives. +/// Hex of the serialized coinbase tx - the `coinbase_body` a worker receives. /// Its optional `extend` block must be present or the worker's own /// `set_mining_nonce` becomes a silent no-op (all threads would then share one /// coinbase hash); `create_coinbase_tx` always emits it. @@ -2954,7 +3423,7 @@ pub fn mine_and_submit_block( "{\"ok\":false,\"err\":\"could not fetch a template from the node\"}".to_string(), ); }; - let cbtx = mint::create_coinbase_tx(tpl.height, Fixed16::default(), tpl.coinbase_addr.clone()); + let cbtx = mint::create_coinbase_tx(tpl.height, Fixed16::default(), tpl.coinbase_addr); let mut trshxs: Vec = vec![cbtx.hash_with_fee()]; let mut transactions = DynVecTransaction::default(); @@ -2972,7 +3441,7 @@ pub fn mine_and_submit_block( version: Uint1::from(1), height: BlockHeight::from(tpl.height), timestamp: Timestamp::from(tpl.timestamp), - prevhash: tpl.prevhash.clone(), + prevhash: tpl.prevhash, mrklroot: calculate_mrklroot(&trshxs), transaction_count: Uint4::from(count), }, @@ -3020,6 +3489,92 @@ mod tests { use protocol::action::HacToTrs; + #[test] + fn the_fee_reserve_is_measured_against_the_transactions_it_has_to_fund() { + // B5. The reserve is subtracted ONCE, in distributable_units, while the + // network fee is paid PER transaction. Nothing compared the two, so a + // settlement cut into more chunks than the reserve covers signed + // transactions the wallet could not fund and the node refused the tail - + // with nothing anywhere saying why. + + // The arithmetic, stated once so it cannot drift: chunk_tx_fee is + // Amount::coin(1, 246) and PAYOUT_UNIT is 247, one step coarser, so a + // chunk costs a TENTH of a unit. The shipped reserve is 5 units. + assert_eq!(CHUNK_FEE_TENTHS, 1); + assert_eq!(FEE_UNITS_PER_TENTH, 10); + let (recipients, chunks) = reserve_funds_recipients(SETTLE_RESERVE_UNITS); + assert_eq!(chunks, 50, "0.5 HAC at 0.01 HAC a transaction"); + assert_eq!( + recipients, + 50 * PAYOUT_CHUNK, + "which is 9500 recipients: far away, and nothing checked it" + ); + + // Chunking is the same ceiling division the settlement uses. + assert_eq!(chunks_needed(0), 0); + assert_eq!(chunks_needed(1), 1); + assert_eq!(chunks_needed(PAYOUT_CHUNK), 1); + assert_eq!( + chunks_needed(PAYOUT_CHUNK + 1), + 2, + "one over is a second tx" + ); + + // A reserve of nothing funds nothing. This must not read as "unlimited", + // which is what an unchecked plan effectively assumed. + assert_eq!(reserve_funds_recipients(0), (0, 0)); + + // And it scales the way an operator would expect when they raise it. + let (bigger, _) = reserve_funds_recipients(SETTLE_RESERVE_UNITS * 2); + assert_eq!(bigger, 2 * recipients); + } + + #[test] + fn an_unreadable_ledger_is_refused_and_only_a_real_object_is_read() { + let base = tmp_path("classify"); + + // No file: a first run, and starting empty is correct. + let missing = format!("{base}.missing"); + assert!(matches!(classify_state_file(&missing), StateFile::Fresh)); + + // A JSON object: readable, whatever money it does or does not carry. + let good = format!("{base}.good"); + std::fs::write(&good, r#"{"schema":1,"owed":[]}"#).expect("write"); + assert!(matches!(classify_state_file(&good), StateFile::Readable(_))); + + // A file with NO schema key is schema 1 - every file written before the + // key existed is exactly that, and must still load. + let legacy = format!("{base}.legacy"); + std::fs::write(&legacy, r#"{"accepted":5}"#).expect("write"); + assert!(matches!( + classify_state_file(&legacy), + StateFile::Readable(_) + )); + + // Every one of these used to leave the pool running with empty + // accounting - zero owed, zero paid, zero in flight - which distributes + // the whole wallet to the current window. All must be Unreadable now. + for (tag, body) in [ + ("truncated", r#"{"owed":[["addr",1"#), // a half-written file + ("array", "[]"), + ("null", "null"), + ("number", "42"), + ("string", r#""text""#), + ("future", r#"{"schema":9999,"owed":[]}"#), + ] { + let p = format!("{base}.{tag}"); + std::fs::write(&p, body).expect("write"); + assert!( + matches!(classify_state_file(&p), StateFile::Unreadable(_)), + "{tag} ({body}) must be refused, not read as empty accounting" + ); + let _ = std::fs::remove_file(&p); + } + for p in [missing, good, legacy] { + let _ = std::fs::remove_file(&p); + } + } + /// A scratch path under the system temp dir, unique per test and per run. fn tmp_path(tag: &str) -> String { let mut p = std::env::temp_dir(); @@ -3187,6 +3742,66 @@ mod tests { ); } + #[test] + fn a_refusal_that_proves_the_payout_is_on_chain_is_not_a_lost_payout() { + // `mint/src/api/transaction.rs` answers ret=1 in four places, and these + // two are reached only AFTER `state.tx_exist` has found the transaction + // in the chain state. They say "the payout is mined and I cannot show it + // to you", not "it never happened". Reading them as Gone runs + // GoneAction::Forget, which puts the rows back on the owed ledger and + // pays those miners a second time out of the operator's own wallet. + for err in [ + "cannot find block by transaction ptr", + "transaction not found in the block", + ] { + assert_eq!( + classify_payout_tx(&serde_json::json!({ "ret": 1, "err": err })), + PayoutTxState::Unknown, + "{err:?} is reached past tx_exist, so that payout is on chain" + ); + } + // The absence answer is matched WHOLE. A prefix or `contains` match + // folds "transaction not found in the block" straight back into Gone and + // silently undoes everything above. + assert_eq!( + classify_payout_tx(&serde_json::json!({"ret":1,"err":"transaction not found"})), + PayoutTxState::Gone + ); + // And matched exactly, not case-insensitively. The node writes this + // literal in lower case; something answering in another case is not the + // node's handler, and the one branch that can hand rows back to the owed + // ledger must never be wider than the string it was written against. + assert_eq!( + classify_payout_tx(&serde_json::json!({"ret":1,"err":"TRANSACTION NOT FOUND"})), + PayoutTxState::Unknown + ); + // Our own malformed request, and any wording this pool has never seen, + // resolve nothing. Keep the hash; do not re-owe its rows. + for err in [ + "transaction hash format invalid", + "a future node phrases it some other way", + ] { + assert_eq!( + classify_payout_tx(&serde_json::json!({ "ret": 1, "err": err })), + PayoutTxState::Unknown, + "an unrecognised refusal must not decide that money was never paid" + ); + } + // A refusal carrying no `err` at all is not a verdict either, and + // neither is one that hides the text somewhere other than the root: + // `top_value` asks where the node really puts it. + assert_eq!( + classify_payout_tx(&serde_json::json!({"ret":1})), + PayoutTxState::Unknown + ); + assert_eq!( + classify_payout_tx( + &serde_json::json!({"ret":1,"data":{"err":"transaction not found"}}) + ), + PayoutTxState::Unknown + ); + } + #[test] fn an_implausible_balance_is_refused_instead_of_saturating() { // "1:280" used to saturate to u64::MAX, which distributable_units then @@ -3246,6 +3861,45 @@ mod tests { assert!(matches!(balance_answer(&odd), BalanceAnswer::Refused(_))); assert_eq!(balance_answer(&odd).units(), None); + // NO `ret` at all. This is the one that used to be believed: the check + // was `find_u64(j,"ret").is_some_and(|r| r != 0)`, so a missing ret fell + // through to a whole-document search for `hacash`, and ANY json carrying + // that key anywhere became a balance the settlement would split. A + // captive portal, a misrouted service or a stale cache could hand the + // pool a number the node never said. + let no_ret = serde_json::json!({"list":[{"hacash":"999999:248"}]}); + assert!( + matches!(balance_answer(&no_ret), BalanceAnswer::Refused(_)), + "a body with no root ret is not the node answering: {:?}", + balance_answer(&no_ret) + ); + assert_eq!(balance_answer(&no_ret).units(), None); + + // A `ret` that is not at the root does not count either: the envelope is + // what says the node is speaking, and finding the word somewhere inside + // a document is not the same thing. + let buried = serde_json::json!({"data":{"ret":0},"list":[{"hacash":"999999:248"}]}); + assert!(matches!(balance_answer(&buried), BalanceAnswer::Refused(_))); + + // More than one row. Every caller asks about ONE address - the pool's + // own wallet - so an answer carrying several is not an answer to the + // question asked, and taking the first would be guessing which is ours. + let many = serde_json::json!({ + "ret":0, + "list":[{"hacash":"1:248"},{"hacash":"999999:248"}] + }); + assert!(matches!(balance_answer(&many), BalanceAnswer::Refused(_))); + assert_eq!(balance_answer(&many).units(), None); + + // A transport error whose text carries a quote still produces a document + // with the http_error key. Built by hand, this was a body that was not + // JSON at all, which fell back to a bare string and lost the key. + let quoted = transport_failure(&r#"connect to "node": refused \ hard"#); + assert!( + matches!(balance_answer("ed), BalanceAnswer::NoAnswer(_)), + "a quoted error text must still be a transport failure: {quoted}" + ); + // A wallet holding nothing. The node renders it "0:0", and that IS a // balance: settlement must go on treating it as a real, actionable zero, // or an empty pool wallet would freeze payouts forever. @@ -3369,13 +4023,17 @@ mod tests { let j = |s: &str| serde_json::from_str::(s).expect("json"); let ours = "aa".repeat(32); let theirs = "bb".repeat(32); + // Heights for the tip rule: 500 is a block the node has not reached + // (tip 400), so a refusal there is ordinary waiting. // Our block, with two transactions to price. assert_eq!( block_txs_of( &j(&format!( r#"{{"ret":0,"hash":"{ours}","tx_hash_list":["11","22"]}}"# )), - &ours + &ours, + 500, + 400 ), BlockTxs::Ours(vec!["11".to_string(), "22".to_string()]) ); @@ -3384,25 +4042,51 @@ mod tests { assert_eq!( block_txs_of( &j(&format!(r#"{{"ret":0,"hash":"{ours}","tx_hash_list":[]}}"#)), - &ours + &ours, + 500, + 400 ), BlockTxs::Ours(vec![]) ); - // Another block won that height, or the chain has not reached it: it - // credited this pool nothing, so there are no fees to hold back. + // Another block won that height: the node ANSWERED, and the chain + // credited this pool nothing there, so there are no fees to hold back. + // Definitive whichever side of the tip the height is on. assert_eq!( block_txs_of( &j(&format!( r#"{{"ret":0,"hash":"{theirs}","tx_hash_list":[]}}"# )), - &ours + &ours, + 300, + 400 ), BlockTxs::NotOnChain ); + // No block at a height ABOVE the node's tip: ordinary waiting, our + // block simply has not been inserted yet. assert_eq!( - block_txs_of(&j(r#"{"ret":1,"err":"cannot find block"}"#), &ours), + block_txs_of( + &j(r#"{"ret":1,"err":"cannot find block"}"#), + &ours, + 500, + 400 + ), BlockTxs::NotOnChain ); + // The SAME refusal at a height the node's own tip covers is NOT an + // answer: the node must hold a block there and could not produce it. + // Our block may be canonical at that height with its fee income already + // sitting in the wallet, so this has to stop settlement, not price the + // fees at zero. Both at the tip exactly and below it. + for h in [400u64, 300] { + assert!( + matches!( + block_txs_of(&j(r#"{"ret":1,"err":"cannot find block"}"#), &ours, h, 400), + BlockTxs::Unknown(_) + ), + "a refusal at height {h} under tip 400 was read as an answer" + ); + } // Everything else is UNKNOWN, and the caller must stop settling. Reading // any of these as "no fees" pays a block's fee income out at zero // confirmations, and an orphan then leaves the operator funding it. @@ -3413,12 +4097,15 @@ mod tests { &format!(r#"{{"ret":0,"hash":"{ours}","tx_hash_list":[7]}}"#), ] { assert!( - matches!(block_txs_of(&j(not_an_answer), &ours), BlockTxs::Unknown(_)), + matches!( + block_txs_of(&j(not_an_answer), &ours, 500, 400), + BlockTxs::Unknown(_) + ), "{not_an_answer} was treated as an answer" ); } assert!(matches!( - block_txs_of(&Value::String("502".into()), &ours), + block_txs_of(&Value::String("502".into()), &ours, 500, 400), BlockTxs::Unknown(_) )); @@ -3651,6 +4338,39 @@ mod tests { ); } + #[test] + fn the_manual_settler_takes_the_pool_fee_from_the_same_constant_as_the_server() { + // The terms above are meant to be stated ONCE, and hbit-pool-payout is + // named right here as one of the two things that apply them. It did not + // apply this one: it passed a literal 0 as the fee to split_payout, which + // agreed with POOL_FEE_UNITS only for as long as POOL_FEE_UNITS stayed 0. + // Set a fee and the two settlers divide the same pot differently, so + // which one an operator happened to run decides what every miner is paid. + // + // Nothing behavioural can see that while the fee is 0, so read the + // source: the fee the manual settler passes must NAME the constant rather + // than carry a copy of today's value. + let src = include_str!("payout.rs"); + let (_, call) = src + .split_once("split_payout(") + .expect("hbit-pool-payout must still split the balance with split_payout"); + let (args, _) = call + .split_once(')') + .expect("the split_payout call must still be a single expression"); + let fee = args + .split(',') + .nth(1) + .map(str::trim) + .expect("split_payout takes the pool fee as its second argument"); + assert!( + fee.contains("POOL_FEE_UNITS"), + "hbit-pool-payout passes `{fee}` as the pool fee instead of POOL_FEE_UNITS. \ + A non-zero fee would then make the manual settler and the pool server pay the \ + same share window differently, and which one an operator ran would decide what \ + the miners got." + ); + } + #[test] fn settle_lock_is_exclusive_across_holders() { let wallet = tmp_path("lock-wallet.key"); @@ -3715,6 +4435,7 @@ mod tests { height: 1234, prevhash: leaf(9), timestamp: 1_700_000_000, + prev_timestamp: 1_699_999_700, difficulty: LOWEST_DIFFICULTY, target: [0xff; 32], coinbase_addr: Address::default(), diff --git a/hbit-pool/src/main.rs b/hbit-pool/src/main.rs index 4d60dee0..a890ef09 100644 --- a/hbit-pool/src/main.rs +++ b/hbit-pool/src/main.rs @@ -1,5 +1,5 @@ //! P1.0 feasibility spike: assemble a coinbase-only block OFF-NODE whose -//! coinbase pays a CHOSEN address, CPU-mine it, and submit via /submit/block — +//! coinbase pays a CHOSEN address, CPU-mine it, and submit via /submit/block - //! proving the node accepts an externally-chosen coinbase with no node change. //! Run against a fresh local testnet (chain_id != 0). //! diff --git a/hbit-pool/src/miner.rs b/hbit-pool/src/miner.rs index 9679ee9e..8c1d5d44 100644 --- a/hbit-pool/src/miner.rs +++ b/hbit-pool/src/miner.rs @@ -157,7 +157,7 @@ fn main() { // five minutes, which is tens of gigabytes of pointless disk // wear a day. Rejections and faults still print every time, // because those are the ones an operator needs to see. - if found <= ACCEPT_LOG_FIRST || found % ACCEPT_LOG_EVERY == 0 { + if found <= ACCEPT_LOG_FIRST || found.is_multiple_of(ACCEPT_LOG_EVERY) { println!("height={height} nonce={nonce} accepted (total {found}) -> {r}"); } continue; diff --git a/hbit-pool/src/payout.rs b/hbit-pool/src/payout.rs index 34537de2..ff5f6aa7 100644 --- a/hbit-pool/src/payout.rs +++ b/hbit-pool/src/payout.rs @@ -45,8 +45,8 @@ use hbit_pool::{ Admission, BlockFees, GoneAction, PAYOUT_CHUNK, PAYOUT_DUST_UNITS, PayoutRecord, PayoutTxState, SETTLE_RESERVE_UNITS, SubmitVerdict, WALLET_PASSWORD_ENV, acquire_settle_lock, balance, block_fees, chunk_tx_fee, classify_payout_tx, confirm_payout, deduct_owed, distributable_units, - drop_payout, find_u64, get_json, gone_action, http_client, is_payout_address, - load_immature_blocks, load_or_create_wallet, load_owed, load_paid_ledger, load_payout_records, + drop_payout, find_u64, get_json, gone_action, is_payout_address, load_immature_blocks, + load_or_create_wallet, load_owed, load_paid_ledger, load_payout_records, load_pending_payout_txs, load_pplns_credit, merge_payout_rows, mine_and_submit_block, owe_rows, payout_amount, pool_state_path, post_hex, save_settlement_ledger, settle_lock_path, submit_verdict, take_owed, verify_admitted, @@ -73,10 +73,10 @@ Run it ONLY while hbit-pool-server is stopped, and read the dry run before you c usage: hbit-pool-payout [wallet_file] [reserve_units] [dust_units] [--commit] - Base URL of the pool server, e.g. http://127.0.0.1:9777 - the - same address you started it on. It is asked for the share - window; while the server is stopped, as it must be, that is - read from the accounting file next to the wallet instead. + Kept so existing commands still run, and NOT consulted for + anything. The share window is read from the accounting file + next to the wallet, which is the only copy this tool trusts. + Pass the address you started the server on, or a dash. Base URL of YOUR OWN Hacash fullnode, already running and synced. Normally http://127.0.0.1:8080 in this package. @@ -174,7 +174,18 @@ fn main() { pos.len() )); } - let pool_base = pos[0].trim().trim_end_matches('/').to_string(); + // Still accepted so commands and scripts written for older versions keep + // working, and deliberately never read. It used to name the URL this tool + // asked for its recipient list, which is a decision that belongs to the + // pool's own accounting file and to nothing reachable over a network. An + // argument that is silently inert is its own trap, so the run says so once. + let ignored_pool_base = pos[0].trim().trim_end_matches('/').to_string(); + if !ignored_pool_base.is_empty() && ignored_pool_base != "-" { + println!( + "note: ({ignored_pool_base}) is accepted for compatibility and is not \ + consulted. The share window comes from the accounting file beside the wallet." + ); + } let node = pos[1].trim().trim_end_matches('/').to_string(); let chain = pos[2].trim().to_string(); // A testnet node reads its difficulty window and block time from its OWN @@ -228,7 +239,12 @@ fn main() { let reserve_units = unit_arg(4, "reserve_units", SETTLE_RESERVE_UNITS); let dust_units = unit_arg(5, "dust_units", PAYOUT_DUST_UNITS); - let client = http_client(); + // The same token the server sends, from the same environment variable: this + // tool talks to the same node, and a node bound to anything but loopback + // refuses to answer without it. + let client = hbit_pool::http_client_with_token( + &std::env::var(hbit_pool::NODE_API_TOKEN_ENV).unwrap_or_default(), + ); println!( "== HBIT pool payout ({}) ==", if commit { "COMMIT" } else { "DRY-RUN" } @@ -257,12 +273,13 @@ fn main() { // refuses a missing answer on its own, so this check is here for the // OPERATOR: it turns "cannot value the wallet" into "your fullnode is not // running at this address", which is the sentence somebody can act on. - if find_u64( + // The tip is BOUND, not merely tested: the fee reader below judges what a + // missing block means against this same tip, so the whole run reasons about + // one chain state rather than two reads that a new block could straddle. + let Some(tip) = find_u64( &get_json(&client, &format!("{node}/query/latest")), "height", - ) - .is_none() - { + ) else { eprintln!( "REFUSING to pay: no Hacash fullnode answered at {node}, so this tool cannot read the \ wallet's balance or what is already in flight.\n\ @@ -271,7 +288,7 @@ fn main() { API address." ); std::process::exit(1); - } + }; let pool_acc = load_or_create_wallet(&wallet_file); let pool_addr = pool_acc.readable().to_string(); let bal = balance(&client, &node, &pool_addr); @@ -290,6 +307,27 @@ fn main() { // that is only shallowly confirmed, or whose state we could not determine, // counts as still in flight. let state_file = pool_state_path(&wallet_file); + // The SAME gate the server applies at startup, for the same reason and with + // more force: this is the tool an operator reaches for AFTER the server has + // refused to start, and it signs real transactions off this file. Without + // the check here the ledger the server would not touch gets paid from + // anyway, and the upstream refusal is worse than useless - it just points + // the operator at the one path that skips it. A wallet with money and no + // readable ledger means stop, not "pay the whole balance to the window". + match hbit_pool::classify_state_file(&state_file) { + hbit_pool::StateFile::Fresh | hbit_pool::StateFile::Readable(_) => {} + hbit_pool::StateFile::Unreadable(why) => { + eprintln!( + "REFUSING to pay: {why}.\n\ + Nothing was paid and the file was not touched. Paying from empty accounting \ + would hand the current share window the entire wallet balance and forget every \ + debt and every payout already in flight.\n\ + What to do: restore the accounting file from a backup, or fix why it cannot be \ + read, then run this again." + ); + std::process::exit(1); + } + } // The per-worker settlement ledger the pool server keeps. This tool writes to // the SAME file, so it has to carry it forward: a payout it makes that is // never recorded here is one no miner can ever see it was paid, and a payout @@ -435,35 +473,30 @@ fn main() { } } - // 1) PPLNS credit. Try the live pool server first, then fall back to the - // accounting file it left behind - holding the settlement lock means the - // server is stopped, so /stats normally cannot answer at all. + // 1) PPLNS credit, from the pool's OWN accounting file and from nothing else. // - // `credit`, never the `workers` headcount printed beside it: a headcount read - // at the instant of a payout is a number one miner can own outright by - // sitting on its shares and dumping a whole window's worth in the second - // before the split. This tool signs the same transactions the server does, so - // it has to weigh work the same way or it becomes the way round the fix. - let stats = get_json(&client, &format!("{pool_base}/stats")); - let rows = stats - .get("credit") - .and_then(|w| w.as_array()) - .cloned() - .unwrap_or_default(); - let mut counts: Vec<(String, u64)> = rows - .iter() - .filter_map(|r| { - let arr = r.as_array()?; - Some((arr.first()?.as_str()?.to_string(), arr.get(1)?.as_u64()?)) - }) - .collect(); - if counts.is_empty() { - counts = load_pplns_credit(&state_file); - if !counts.is_empty() { - println!( - "(pool server not answering; using the share window recorded in {state_file})" - ); - } + // This used to GET {pool_base}/stats first and touch the file only if that + // answer parsed empty. `pool_base` is argv[1]: plain HTTP, no authentication, + // no cross check against anything. So whatever replied on that URL chose + // every recipient of the whole distributable balance, and this tool signed + // that list with the pool wallet key. An operator typo, a stale DNS name, a + // process that grabbed the port after the server exited, or anyone on the + // path was enough. + // + // The endpoint could not even do the job it was there for. This tool holds + // the exclusive settlement lock for its whole run, so the pool server is by + // construction NOT running while it works - its own comment said as much. + // A /stats that answers here is therefore, on the balance of it, not the + // pool. + // + // `credit`, never a `workers` headcount: a headcount read at the instant of a + // payout is a number one miner can own outright by sitting on its shares and + // dumping a whole window's worth in the second before the split. This tool + // signs the same transactions the server does, so it has to weigh work the + // same way or it becomes the way round that fix. + let counts = load_pplns_credit(&state_file); + if !counts.is_empty() { + println!("using the share window recorded in {state_file}"); } // An empty window is not enough to stop: a chunk that failed while those // miners' shares were in the window is still owed to them long after the @@ -504,10 +537,13 @@ fn main() { if blk.fees_counted { continue; } - match block_fees(&client, &node, blk.height, &blk.hash) { + match block_fees(&client, &node, blk.height, &blk.hash, tip) { BlockFees::Counted(fee) => immature_units = immature_units.saturating_add(fee), // Never landed, or another block took that height: it credited - // nothing, so there are no fees of its to hold back. + // nothing, so there are no fees of its to hold back. A node that + // fails to produce a block at a height its own tip covers does NOT + // land here - block_txs_of turns that into Unknown, and the arm + // below refuses to pay on it. BlockFees::NotOnChain => {} BlockFees::Unknown(why) => { eprintln!( @@ -545,7 +581,18 @@ fn main() { split.len() ); } - split.extend(split_payout(left, 0, dust_units, &payable_counts)); + // The SAME fee the pool server applies, out of the SAME constant. This used + // to be a literal 0, which agreed with POOL_FEE_UNITS only for as long as + // POOL_FEE_UNITS stayed 0: set a fee and the two settlers divide the same pot + // differently, so which one an operator happened to run would decide what + // every miner was paid - and this one would be handing out money the pool + // told its miners over /terms that it was keeping. + split.extend(split_payout( + left, + hbit_pool::POOL_FEE_UNITS, + dust_units, + &payable_counts, + )); // One action per miner: a miner that is owed AND has shares in the window is // paid once, and every action counts against the node's 200-action limit. merge_payout_rows(&mut split); @@ -553,6 +600,23 @@ fn main() { println!("split produced no payable rows (all below dust {dust_units}) - nothing to pay"); return; } + // The pool's own fee comes off the top of the fresh split, and split_payout + // pays it to nobody: it is money simply not handed out, and it stays in this + // wallet. The plan below is the only thing an operator reads before + // committing real money, so a total that is short of the distributable + // balance has to be named here. Unexplained, it reads either as a bug in the + // split or as money that went somewhere nothing names. + // `!= 0` rather than `> 0`: with the fee shipped at 0 clippy const-folds the + // ordering comparison and denies it as always false. The branch still has to + // be here, because the constant is the one thing an operator changes. + if hbit_pool::POOL_FEE_UNITS != 0 { + println!( + "\npool fee: {} unit(s) come off the top of the fresh split before it is divided, \ + the same fee hbit-pool-server takes and /terms advertises. Nobody is paid it: it \ + stays in this wallet.", + hbit_pool::POOL_FEE_UNITS + ); + } let n_tx = split.len().div_ceil(PAYOUT_CHUNK); let plan_units: u64 = split.iter().map(|(_, u)| *u).sum(); println!( @@ -574,6 +638,26 @@ fn main() { return; } + // Does the reserve fund the transactions this plan needs? The reserve is + // subtracted ONCE from the distributable balance while the fee is paid PER + // transaction, so a large enough settlement signs transactions the wallet + // cannot fund and the node refuses the tail. The operator chose this reserve + // on the command line, so this refuses rather than deciding for them: the + // dry run above has already been read, and quietly paying a different set of + // people than the plan that was reviewed would be worse than stopping. + let (fundable, funded_chunks) = hbit_pool::reserve_funds_recipients(reserve_units); + if split.len() > fundable { + eprintln!( + "REFUSING to pay: this settlement needs {} transaction(s) but the reserve of {reserve_units} unit(s) funds only {funded_chunks}, so the last ones would be refused by the node for want of a fee. + Nothing was paid and nothing was changed. + What to do: re-run with a larger reserve_units (argument 5). It needs to be at least {} for these {} recipient(s).", + hbit_pool::chunks_needed(split.len()), + hbit_pool::chunks_needed(split.len()).div_ceil(hbit_pool::FEE_UNITS_PER_TENTH).max(1), + split.len() + ); + std::process::exit(1); + } + // 3) submit one or more chunked, signed transactions. let main = Address::from(*pool_acc.address()); let mut submitted: Vec = Vec::new(); @@ -581,7 +665,7 @@ fn main() { for chunk in split.chunks(PAYOUT_CHUNK) { // 0.01 HAC network fee, from the reserve, built by the same helper the // pool server and `/terms` use. - let mut tx = TransactionType2::new_by(main.clone(), chunk_tx_fee(), curtimes()); + let mut tx = TransactionType2::new_by(main, chunk_tx_fee(), curtimes()); // Exactly what this transaction pays, so a miner can later be told what // it was paid and by which transaction. Only rows that really made it // into the transaction are recorded. @@ -677,9 +761,10 @@ fn main() { } } // ret=0 only means the API took the bytes. The node validates the - // transaction synchronously and then inserts it into the mempool on a - // background task whose result it DISCARDS, so an accepted response is - // no evidence at all. Ask the node what it actually holds. + // transaction synchronously, inserts it into its mempool and relays it + // to its peers before it answers. That is not proof of payment - a + // mempool is not the chain - so ask the node what it actually holds, + // and read the answer knowing these bytes are already out there. let held = match verify_admitted(&client, &node, &txhash) { Admission::Held => { println!( @@ -693,17 +778,28 @@ fn main() { true } Admission::Missing => { + // The same correction as the server settler, for the same + // reason. This arm used to drop the record - the only copy of + // the signed bytes - and put the rows back on the owed ledger, + // on the belief that a node which does not hold a transaction + // never relayed it. + // + // This node relays before it answers: submit_transaction reads + // `async` as false by default and nothing here sends it, so + // handle_new_tx runs txpool.insert_by and then + // p2p.broadcast_message before returning Ok. A ret=0 already + // means the bytes are on the wire. Losing them here is what + // makes the next run sign a second transaction for the same + // miners, and if any peer still holds the first, both can be + // mined and the operator pays twice. all_ok = false; println!( - " tx {} paying {pushed} miner(s): the API accepted it but the node does NOT \ - hold it - nothing was paid and nothing was relayed. These rows are now OWED \ - and the next settlement pays them first.", + " tx {} paying {pushed} miner(s): the node accepted it and does NOT hold it \ + now. It was relayed before it was lost, so it is NOT being re-signed: the \ + signed bytes are kept and a later run rebroadcasts the same transaction. \ + Nothing is counted as paid until the chain buries it.", short(&txhash) ); - submitted.retain(|h| h != &txhash); - if let Some(rec) = drop_payout(&mut records, &txhash) { - owe_rows(&mut owed, &rec.rows); - } let _ = save_settlement_ledger(&state_file, &submitted, &records, &owed, &paid); false } diff --git a/hbit-pool/src/pool_core.rs b/hbit-pool/src/pool_core.rs index d07d8e2e..cc4adbaa 100644 --- a/hbit-pool/src/pool_core.rs +++ b/hbit-pool/src/pool_core.rs @@ -1,4 +1,4 @@ -//! pool_core — the pool's off-node accounting brain. No consensus, no node +//! pool_core - the pool's off-node accounting brain. No consensus, no node //! changes. Ties the two proven on-chain halves together: workers submit shares //! (validated here) -> PPLNS accounting -> exact payout split -> the proven //! batched settlement transfer. @@ -158,6 +158,45 @@ pub struct Pplns { /// already earned, and destroying other people's credit on demand is exactly /// what a burst of withheld shares does. banked: VecDeque<(u64, HashMap)>, + /// Milliseconds of banked credit the [`BANK_WORKERS_MAX`] cap would not + /// hold, counted for the life of this process. + /// + /// The cap has to stay: it is what stops a flood of invented payout + /// addresses growing that map without bound. But credit it turns away was + /// earned by a named miner, and dropping it hands that miner's cut of the + /// next settlement to everyone else. The cap sits far above any honest + /// population, so refusing anything at all is already an event; refusing it + /// with no record was the real defect, because nothing then told the + /// operator a payout had been paid short. Anything but 0 says it has. + banked_refused_ms: u64, +} + +/// Cap a banked bucket at [`BANK_WORKERS_MAX`], returning the credit it could +/// not hold. +/// +/// WHICH miners a full bucket turns away must not be an accident of how the file +/// happened to be ordered. `banked_snapshot` writes each bucket's rows sorted by +/// worker id, so the plain `take` this replaces deleted the banked credit of the +/// alphabetically-LAST miners on every restart and split it over everyone else, +/// with nothing anywhere to say so. The smallest credits go instead, and what +/// went is returned to be counted. +/// +/// Only ever called on the restore path. Sorting a full bucket is 65,536 rows, +/// which is nothing once at startup and would be intolerable per share under the +/// pool's global lock. +fn cap_bucket(map: &mut HashMap) -> u64 { + if map.len() <= BANK_WORKERS_MAX { + return 0; + } + let mut kept: Vec<(String, u64)> = std::mem::take(map).into_iter().collect(); + // Largest credit first, ties broken by worker id so the same file always + // produces the same pool. + kept.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let refused = kept + .drain(BANK_WORKERS_MAX..) + .fold(0u64, |acc, (_, ms)| acc.saturating_add(ms)); + *map = kept.into_iter().collect(); + refused } impl Pplns { @@ -168,6 +207,7 @@ impl Pplns { order: VecDeque::new(), counts: HashMap::new(), banked: VecDeque::new(), + banked_refused_ms: 0, } } @@ -224,16 +264,33 @@ impl Pplns { if fresh { self.banked.push_back((bucket, HashMap::new())); } + // Recorded after the bucket borrow ends: the counter lives on the pool, + // not in the bucket. + let mut refused = 0u64; if let Some((_, rows)) = self.banked.back_mut() { match rows.get_mut(worker) { Some(v) => *v = v.saturating_add(earned), None => { if rows.len() < BANK_WORKERS_MAX { rows.insert(worker.to_string(), earned); + } else { + // The bucket is full, so this miner's evicted share keeps + // nothing of what it earned and the settlement splits it + // over everyone else. The cap stays: it is the only thing + // between this map and a flood of invented addresses. But + // the loss goes on the record instead of vanishing. + // + // The smallest row is deliberately NOT hunted down and + // replaced here the way `cap_bucket` does it on restore. + // That is a scan of 65,536 rows under the pool's global + // lock on EVERY share, and the same flood that fills the + // bucket would be triggering that scan for free. + refused = earned; } } } } + self.banked_refused_ms = self.banked_refused_ms.saturating_add(refused); } fn bucket_ms(&self) -> u64 { @@ -328,6 +385,14 @@ impl Pplns { self.horizon_ms } + /// Milliseconds of banked credit the worker cap turned away since this + /// process started. 0 on any honest pool, and read out so that a payout paid + /// short is something an operator can see rather than something the + /// accounting swallowed. + pub fn banked_refused_ms(&self) -> u64 { + self.banked_refused_ms + } + /// Number of shares currently in the window. pub fn total(&self) -> u64 { self.order.len() as u64 @@ -402,9 +467,19 @@ impl Pplns { ) -> Self { let mut p = Self::new(window, horizon_ms); let keep = p.window; + // The instant this snapshot was taken: the newest arrival in it. The + // shares trimmed below are being evicted right now, and what they earned + // is measured against that instant, exactly as `record` measures against + // the arrival that evicts them. + let anchor = order.iter().map(|(_, at)| *at).max().unwrap_or(0); // Newest first, pushed to the FRONT, so the surviving tail comes back in // the order it was accepted in. - for (w, at) in order.into_iter().rev().take(keep) { + let mut trimmed: Vec<(String, u64)> = Vec::new(); + for (i, (w, at)) in order.into_iter().rev().enumerate() { + if i >= keep { + trimmed.push((w, at)); + continue; + } p.order.push_front((w.clone(), at)); match p.counts.get_mut(&w) { Some(c) => *c += 1, @@ -413,14 +488,35 @@ impl Pplns { } } } + // BANK what was trimmed, the way `record` banks what it evicts. + // + // This used to drop it. `record` banks precisely so that eviction cannot + // destroy credit - the comment there says a miner able to push 4096 + // shares in at once would otherwise wipe out everyone else's accrued + // credit for free - and restore quietly did the opposite. A file holding + // more shares than this build's window (written when the window was + // larger, or by an operator's hand) lost every share past the constant + // AND everything those shares had earned, which is money moved from the + // miners who were there to the miners who remain. A restart is not + // allowed to move money between people. + // + // Banked at `anchor` because that is when the eviction happens, so the + // bucket expires one horizon after the snapshot rather than one horizon + // after whenever the pool was restarted. + for (w, since) in trimmed { + let earned = anchor.saturating_sub(since).min(p.horizon_ms); + p.bank(&w, earned, anchor); + } let width = p.bucket_ms(); for (at_ms, rows) in banked { let bucket = at_ms / width; let mut map: HashMap = HashMap::new(); - for (w, ms) in rows.into_iter().take(BANK_WORKERS_MAX) { + for (w, ms) in rows { let e = map.entry(w).or_insert(0); *e = e.saturating_add(ms); } + let refused = cap_bucket(&mut map); + p.banked_refused_ms = p.banked_refused_ms.saturating_add(refused); // Keep the persisted order: expiry pops from the front, so a bucket // out of sequence would drop credit that is still current. match p.banked.back() { @@ -431,6 +527,14 @@ impl Pplns { *e = e.saturating_add(ms); } } + // This merge is the one path that can push a bucket past the + // cap from a file THIS build wrote: two persisted buckets + // falling in one bucket index are folded together with no + // bound of their own. Capped here as well, or `bank` would + // spend the rest of the run refusing every share into an + // oversized bucket it can never shrink. + let over = p.banked.back_mut().map_or(0, |(_, b)| cap_bucket(b)); + p.banked_refused_ms = p.banked_refused_ms.saturating_add(over); } _ => p.banked.push_back((bucket, map)), } @@ -447,7 +551,7 @@ impl Pplns { } /// Split `reward_units` (smallest integer units) among workers by share count -/// using the largest-remainder method (exact — no unit created or lost), after +/// using the largest-remainder method (exact - no unit created or lost), after /// taking `fee_units` off the top. Workers whose payout is below `dust_units` /// are dropped (their remainder stays with the pool). Returns (worker, units). pub fn split_payout( @@ -493,6 +597,125 @@ mod tests { /// change uses the interval->horizon map the server uses. use crate::pplns_horizon_ms; + #[test] + fn a_restart_that_trims_the_window_does_not_move_money_between_miners() { + // B9. `record` banks what it evicts, precisely so eviction cannot destroy + // credit. `restore` dropped it. A file holding more shares than this + // build's window - written when the window was larger, or edited by hand - + // lost every share past the constant AND everything those shares had + // earned. That credit belongs to named miners, and dropping it hands + // their money to whoever is still in the window. A restart must not do + // that in either direction. + let horizon = 600_000u64; + let t = 1_000_000u64; + + // Six shares in the file: A mined early and is about to be trimmed, + // B mined late and survives. + let order = vec![ + ("A".to_string(), t), + ("A".to_string(), t + 1_000), + ("A".to_string(), t + 2_000), + ("B".to_string(), t + 100_000), + ("B".to_string(), t + 101_000), + ("B".to_string(), t + 102_000), + ]; + // A window of two: four of those six are evicted on restore. + let p = Pplns::restore(2, horizon, order.clone(), Vec::new()); + let credit = p.credit(t + 102_000); + let a = credit + .iter() + .find(|(w, _)| w == "A") + .map(|(_, c)| *c) + .unwrap_or(0); + assert!( + a > 0, + "the miner whose shares were trimmed still earned them: {credit:?}" + ); + + // And the whole point: trimming must not change the split. Restoring the + // same file into a window big enough to hold all six has to credit the + // same people the same way. + let whole = Pplns::restore(16, horizon, order, Vec::new()); + let want = whole.credit(t + 102_000); + let got = credit; + let total_want: u64 = want.iter().map(|(_, c)| *c).sum(); + let total_got: u64 = got.iter().map(|(_, c)| *c).sum(); + assert_eq!( + total_got, total_want, + "the same file must be worth the same credit whatever window it is \ + restored into: trimmed {got:?} against whole {want:?}" + ); + for (w, c) in &want { + assert_eq!( + got.iter().find(|(x, _)| x == w).map(|(_, c)| *c), + Some(*c), + "worker {w} is credited differently after a trim: {got:?} against {want:?}" + ); + } + } + + #[test] + fn banked_credit_the_worker_cap_turns_away_is_counted_never_silently_dropped() { + // One banked bucket holds at most BANK_WORKERS_MAX workers, so a flood of + // invented payout addresses cannot grow it without bound. The cap stays. + // What it must not do is make a named miner's earned credit disappear + // with no record: that credit is money, and dropping it hands the miner's + // cut of the next settlement to everyone else. + // + // It was dropped in two places. `restore` enforced the cap with `take`, + // which keeps whatever the file listed FIRST, and `banked_snapshot` + // writes the rows sorted by worker id, so a restart deleted the banked + // credit of the alphabetically-last miners. `bank` simply skipped the + // insert once the live bucket was full. + let horizon = 600_000u64; + let at = 1_700_000_000_000u64; // a real clock, well inside its bucket + let over = 4usize; + // One bucket's rows as a state file carries them: ascending worker ids, + // with the credit RISING down the list, so the miners `take` threw away + // are precisely the ones holding the most. + let mut rows: Vec<(String, u64)> = (0..BANK_WORKERS_MAX + over) + .map(|i| (format!("w{i:07}"), 1_000 + i as u64)) + .collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + let richest = rows.last().cloned().expect("the file has rows"); + assert_eq!(richest, ("w0065539".to_string(), 66_539)); + + let mut p = Pplns::restore(1, horizon, Vec::new(), vec![(at, rows)]); + + // The cap was applied, so the map is still bounded ... + let (_, kept) = p.banked_snapshot().first().cloned().expect("one bucket"); + assert_eq!(kept.len(), BANK_WORKERS_MAX); + // ... and it fell on the four SMALLEST credits, not on whoever the file + // happened to list last. + assert_eq!( + p.credit_share(&richest.0, at).0, + richest.1, + "the cap picked its victim by worker id: the miner holding the most \ + banked credit in the bucket lost all of it on a restart" + ); + assert_eq!(p.credit_share("w0000000", at).0, 0); + // 1000 + 1001 + 1002 + 1003: what the four dropped rows were worth. + assert_eq!( + p.banked_refused_ms(), + 4_006, + "credit the cap refused on restore is not on the record" + ); + + // And the same on the live path: the restored bucket is full, so the + // next eviction into it is refused too, and that refusal is counted. + p.record("evicted", at + 1); + p.record("other", at + 2); // evicts "evicted" after 1ms of residence + assert_eq!( + p.banked_refused_ms(), + 4_007, + "credit the cap refused on an eviction is not on the record" + ); + assert_eq!(p.credit_share("evicted", at + 2).0, 0); + // Still bounded: a refusal never grows the map. + let (_, after) = p.banked_snapshot().first().cloned().expect("one bucket"); + assert_eq!(after.len(), BANK_WORKERS_MAX); + } + #[test] fn shift_left_saturating_multiplies_and_saturates() { // 0x01 at byte 16, x16 (<<4) -> 0x10 at byte 16. @@ -762,7 +985,10 @@ mod tests { // by the ratio of the two bucket widths, so credit banked a second ago // read as decades old and was expired on the spot. let h120 = pplns_horizon_ms(120); - assert!(bucket_ms + 1_000 < h120, "the test must not sit on the edge"); + assert!( + bucket_ms + 1_000 < h120, + "the test must not sit on the edge" + ); let shorter = Pplns::restore(1, h120, order.clone(), banked.clone()); assert_eq!( shorter.credit_share("gone", now).0, diff --git a/hbit-pool/src/server.rs b/hbit-pool/src/server.rs index 5dbd3a62..1f491c41 100644 --- a/hbit-pool/src/server.rs +++ b/hbit-pool/src/server.rs @@ -53,7 +53,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::io::{BufReader, Read, Write}; use std::net::{IpAddr, Shutdown, TcpListener, TcpStream}; use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; -use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; +use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use basis::interface::*; @@ -65,18 +65,20 @@ use sys::{Account, curtimes}; use hbit_pool::difficulty::ChainParams; use hbit_pool::pool_core::{self, Pplns, split_payout}; use hbit_pool::{ - Admission, BalanceAnswer, BlockFees, DEFAULT_SETTLE_SECS, GoneAction, PAYOUT_CHUNK, - PAYOUT_DUST_UNITS, PAYOUT_MATURITY_DEPTH, PAYOUT_UNIT, POOL_FEE_UNITS, PPLNS_WINDOW, - PaidLedger, PayoutRecord, PayoutTxState, SETTLE_RESERVE_UNITS, StampPin, SubmitVerdict, - Template, WALLET_PASSWORD_ENV, WALLET_PASSWORD_FILE_ENV, acquire_settle_lock, assemble_block, - atomic_write, balance, block_fees, block_reward_units, chunk_tx_fee, classify_payout_tx, - coinbase_body_hex, coinbase_with_extranonce, confirm_payout, deduct_owed, distributable_units, - drop_payout, fetch_pool_template, find_str, find_u64, get_json, gone_action, http_client, - intro_bytes, is_payout_address, load_or_create_wallet, merge_payout_rows, owe_rows, - owed_to_json, parse_banked_credit, parse_owed, parse_paid_ledger, parse_payout_records, - parse_share_order, payout_amount, pool_state_path, post_hex, pplns_horizon_ms, - settle_lock_path, submit_block_bytes, submit_verdict, take_owed, verify_admitted, - verify_chain_params, + Admission, BalanceAnswer, BlockFees, DEFAULT_SETTLE_SECS, GoneAction, NODE_API_TOKEN_ENV, + PAYOUT_CHUNK, PAYOUT_DUST_UNITS, PAYOUT_MATURITY_DEPTH, PAYOUT_UNIT, POOL_FEE_UNITS, + PPLNS_WINDOW, PaidLedger, PayoutRecord, PayoutTxState, SETTLE_RESERVE_UNITS, STATE_SCHEMA, + StampPin, StateFile, SubmitVerdict, TIP_STALE_SECS_WHILE_RUNNING, Template, + WALLET_PASSWORD_ENV, WALLET_PASSWORD_FILE_ENV, acquire_settle_lock, assemble_block, + atomic_write, balance, block_fees, block_reward_units, chunk_tx_fee, chunks_needed, + classify_payout_tx, classify_state_file, coinbase_body_hex, coinbase_with_extranonce, + confirm_payout, deduct_owed, distributable_units, drop_payout, fetch_pool_template, find_str, + find_u64, get_json, gone_action, http_client, http_client_with_token, intro_bytes, + is_payout_address, load_or_create_wallet, merge_payout_rows, owe_rows, owed_to_json, + parse_banked_credit, parse_owed, parse_paid_ledger, parse_payout_records, parse_share_order, + payout_amount, pool_state_path, post_hex, pplns_horizon_ms, reserve_funds_recipients, + settle_lock_path, submit_block_bytes, submit_verdict, take_owed, tip_too_old, unpayable_owed, + verify_admitted, verify_chain_params, }; use serde_json::json; @@ -175,9 +177,11 @@ const BAD_STREAK_REPEAT_MS: u64 = 300_000; /// something about the worker. /// /// One mainnet block interval, which comfortably covers a GPU scan pass. The pool -/// pins one template per height and `/query/miner/notice` signals only a HEIGHT -/// change, so a worker legitimately keeps hashing the header it was handed until -/// its current pass ends. Nothing here decides whether the line is printed, only +/// pins one template per height, and `/query/miner/notice` releases a parked rig +/// on a template change but only at its next poll, so a worker legitimately keeps +/// hashing the header it was handed until its current pass ends. This window +/// covers the scan pass, not the notice latency, which is why it stays at a block +/// interval. Nothing here decides whether the line is printed, only /// which sentence follows it: neither wording accuses the worker of anything. const TEMPLATE_SETTLE_MS: u64 = 300_000; /// Quiet time between the accepted-share summary lines on stdout. @@ -222,6 +226,23 @@ const MONEY_REFRESH_CYCLES: u64 = 15; /// reaches its height. At roughly one cycle every two seconds this is about two /// minutes, far longer than a node needs to insert a block it accepted. const BLOCK_STALL_CYCLES: u32 = 60; +/// Backoff between attempts to hand a found block to the node. +/// +/// The pool used to submit exactly once, and the serialized bytes went out of +/// scope on the very next line. A block is the rarest and most valuable thing +/// this pool handles - a whole subsidy plus every fee packed into it - so one +/// dropped connection lost it permanently, with one stderr line. The solo miner +/// in this same workspace has retried with backoff for exactly this reason. +/// +/// Five attempts spread over 7.5s. The winning worker's request waits for this, +/// which is why it is bounded rather than generous: a rig that gets no answer +/// resubmits by itself, and the pool's replay set recognises the resubmission. +const BLOCK_SUBMIT_RETRY_DELAYS: &[Duration] = &[ + Duration::from_millis(500), + Duration::from_millis(1_000), + Duration::from_millis(2_000), + Duration::from_millis(4_000), +]; /// Bounds on the automatic settlement interval. Each settlement is a signed /// on-chain transaction carrying a network fee, so running one every few seconds /// spends the reserve for nothing; `0` is worse still, because the timer thread @@ -230,10 +251,29 @@ const BLOCK_STALL_CYCLES: u32 = 60; /// should say so by stopping the pool, not by typing a large number. const MIN_SETTLE_SECS: u64 = 30; const MAX_SETTLE_SECS: u64 = 86_400; -/// The documented default share size. `usage()` quotes it, so the help text -/// cannot describe a default the code does not use. The settlement interval's -/// default lives beside the credit horizon it sizes, in `hbit_pool`. -const DEFAULT_SHARE_BITS: u32 = 24; +/// The documented default share size, and the ONE value every shipped +/// deployment file uses. `usage()` quotes it, so the help cannot recommend a +/// number the project does not. +/// +/// It was 24, and the repository shipped 24 in the systemd unit while shipping +/// 20 in docker-compose and 20 in the VPS setup script. Worse, the compose file +/// carried the written argument AGAINST 24, so the program's own help text was +/// recommending the value its own documentation argued against. +/// +/// 20, and the reason is payout fairness rather than throughput. A share costs +/// (block work - share_bits) hashes. Block work measured on this chain is 2^42, +/// so at 24 a share costs 2^18 and an ordinary card produces roughly 27 a +/// second; ten miners turn the whole 4096-share window over in about fifteen +/// seconds, and a miner that drops off for half a minute loses everything it +/// had earned. At 20 a share costs 2^22, the same pool keeps about four minutes +/// of history, and the window stops being a lottery on connection stability. +/// +/// Raise it only if miners report too few shares to be paid smoothly, and never +/// past (block work - 16), which the pool enforces and explains. +/// +/// The settlement interval's default lives beside the credit horizon it sizes, +/// in `hbit_pool`. +const DEFAULT_SHARE_BITS: u32 = 20; /// The largest hashrate the pool will believe from ONE worker id, as a power of /// two hashes per second. Deliberately far above any real x16rs farm: this is a /// ceiling on the absurd, not a throttle on big miners. @@ -254,7 +294,18 @@ const WORKER_BURST_MIN_SHARES: u64 = 64; /// Workers tracked by the rate limiter. Bounded so a flood of invented payout /// addresses cannot grow memory; entries that would have refilled to full are /// pruned first, so pruning never hands anyone credit it should not have. +/// +/// A bound on MEMORY, not a promise about shares. With this many ids all still +/// active there is nowhere to record the next one, and `rate_admits_share` then +/// admits it UNTRACKED rather than refuse an honest miner work it has already +/// paid for in power. Anyone reading this figure for a guarantee has to read +/// that function too. const RATE_WORKERS: usize = 100_000; +/// How long the "the rate limiter is open" line is suppressed after being +/// printed, while the condition lasts. Five minutes, like the above-target +/// streak line: an incident that runs all afternoon is a handful of lines, and +/// an operator reading the log still sees that it has not stopped. +const RATE_OPEN_REPEAT_MS: u64 = 300_000; /// Shortest passphrase the wallet layer accepts. Mirrored here only so a short /// one is a refusal that says what to do, instead of a panic out of the key /// loader with a backtrace note on it. The loader still has the final say: if @@ -266,8 +317,156 @@ static NOTICE_WAITERS: AtomicUsize = AtomicUsize::new(0); static PER_IP: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); /// Last printed reason (and when) for mining without the node's transactions. static TX_WARN: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +/// A submitted block, by the height and hash that identify it on the chain. +type BlockKey = (u64, [u8; 32]); /// Submitted blocks the chain has not reached yet, and for how many cycles. -static BLOCK_STALL: LazyLock>> = +static BLOCK_STALL: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +/// How often a dropped-connection notice may repeat. +/// +/// A full connection table fires on EVERY accept, so an unconditional line would +/// be a log that scrolls its own explanation away during the incident it is +/// describing. Once every thirty seconds keeps it readable and still makes the +/// incident impossible to miss. +const CONN_DROP_EVERY: Duration = Duration::from_secs(30); +/// The last dropped-connection notice printed, and when. +static CONN_DROP_LOG: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +/// Print a connection-drop notice unless the same one was printed recently. +/// +/// Keyed on the TEXT, so "the table is full" and "this IP is at its limit" do +/// not silence each other: they have different causes and different fixes. +fn drop_notice(state: &mut Option<(String, Instant)>, now: Instant, text: &str) { + let repeat = match state.as_ref() { + Some((last, at)) => last != text || now.duration_since(*at) >= CONN_DROP_EVERY, + None => true, + }; + if repeat { + eprintln!("{text}"); + *state = Some((text.to_string(), now)); + } +} + +/// The share of the hash gate any ONE source may hold at once, as a divisor of +/// the total. A quarter: an attacker holding their full MAX_PER_IP connections +/// still leaves three quarters of the machine for everybody else. +const HASH_PEER_SHARE_DIVISOR: usize = 4; + +/// Bounds how many x16rs verifications run at once, and how many of those one +/// source may hold. +/// +/// Verifying a submission is deliberately slow, it happens off the pool lock, +/// and NOTHING bounded how many ran at the same time: a connection is a thread, +/// MAX_CONNS is 1024, and every one of them could be computing. An +/// unauthenticated client bought one slow hash per roughly 100-byte GET, and at +/// scale the machine's entire hashing capacity went into verifying garbage while +/// an honest miner's share - and the winning worker's block submission - waited +/// behind it. +/// +/// This does NOT refuse anything. A submission that has to wait still gets +/// verified, because a submission may turn out to be a block and this pool never +/// throws one away to save CPU. What it stops is a thousand CPU-bound threads +/// thrashing one machine, and it stops any single source occupying all of it. +/// +/// The per-source share is why this is not merely tidier. Bounding the total +/// alone would let one IP's MAX_PER_IP connections take every permit; with a +/// share, honest miners keep making progress while an attacker is throttled to +/// their quarter. +struct HashGate { + /// (verifications running, how many each peer is running). + state: Mutex<(usize, HashMap)>, + room: Condvar, + total: usize, + per_peer: usize, +} + +impl HashGate { + fn new(total: usize) -> Self { + let total = total.max(2); + Self { + state: Mutex::new((0, HashMap::new())), + room: Condvar::new(), + total, + per_peer: (total / HASH_PEER_SHARE_DIVISOR).max(1), + } + } + + /// Wait for room, then hold a permit until the returned guard is dropped. + fn enter(&self, peer: &str) -> HashPermit<'_> { + let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner()); + loop { + let mine = st.1.get(peer).copied().unwrap_or(0); + if st.0 < self.total && mine < self.per_peer { + st.0 += 1; + *st.1.entry(peer.to_string()).or_insert(0) += 1; + return HashPermit { + gate: self, + peer: peer.to_string(), + }; + } + st = self.room.wait(st).unwrap_or_else(|e| e.into_inner()); + } + } +} + +/// Releases a hash permit on scope exit, INCLUDING on an unwind: a panicking +/// verification must not leak a permit and shrink the gate for the life of the +/// process. +struct HashPermit<'a> { + gate: &'a HashGate, + peer: String, +} + +impl Drop for HashPermit<'_> { + fn drop(&mut self) { + let mut st = self.gate.state.lock().unwrap_or_else(|e| e.into_inner()); + st.0 = st.0.saturating_sub(1); + if let Some(c) = st.1.get_mut(&self.peer) { + *c -= 1; + if *c == 0 { + st.1.remove(&self.peer); + } + } + drop(st); + self.gate.room.notify_all(); + } +} + +/// One permit per core, which is what the work actually contends for. +static HASH_GATE: LazyLock = LazyLock::new(|| { + HashGate::new( + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4), + ) +}); + +/// How long a rendered `/stats` body may be served again without recomputing it. +/// +/// `/stats` is open and unauthenticated, and building it walks the whole share +/// window and every banked credit bucket, allocates a String per worker and +/// sorts. That used to happen under the global pool mutex on every single +/// request, so anyone inside the per-IP allowance could serialize every miner's +/// share submission behind their polling - and a found block needs that same +/// mutex. +/// +/// Two seconds is shorter than the template loop's own cycle, so the page never +/// looks stuck, and it bounds the work to once per interval no matter how hard +/// the endpoint is hit. Nothing money-critical reads this: `hbit-pool-payout` +/// used to take its recipient list from here and no longer does. +const STATS_CACHE_MS: u64 = 2_000; +/// The last rendered `/stats` body and when it was built. +/// +/// LOCK ORDER: this is taken BEFORE the pool mutex and never while holding it. +/// The recompute happens with this held, so a flood of requests produces exactly +/// one computation rather than one per waiting thread. +static STATS_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +/// Heights where the chain is showing a hash that is not ours, and the hash the +/// pool last announced for that height. Both verdicts now wait for burial, so +/// without this the operator would learn about a fork only ~16 blocks after it +/// began; with it they hear once per competing hash, not once per cycle. +static REORG_WATCH: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); /// Lock the pool, recovering from a poisoned mutex instead of cascading panics. @@ -429,6 +628,26 @@ struct Immature { /// for, and `/submit/block` reports only that it took the block. Settlement /// reads it back off the node before it values anything. fees_counted: bool, + /// Who was mining when this block was found, and how much each had earned: + /// the PPLNS credit vector frozen at the instant of discovery. + /// + /// NOTHING IS PAID FROM THIS YET. It is recorded, persisted and reported, + /// and settlement still splits over the LIVE window exactly as before. This + /// is stage one of a change that moves money between people, and the point + /// of recording first is that the two answers can be compared on real blocks + /// before either becomes the one that pays. + /// + /// Why it has to exist at all: a block found at T is payable about eighty + /// minutes later, and the split is taken from whoever holds credit THEN. A + /// miner who connects after the block was found is paid out of it; a miner + /// who leaves before settlement is paid nothing for the work that found it. + /// The reward belongs to the miners whose work was present when the block + /// was found, and this is the only record of who that was - the window has + /// rolled over many times by the time the money moves. + /// + /// Empty means "no snapshot", which is the honest reading for every block + /// found before this field existed. It is never read as "nobody was mining". + claim: Vec<(String, u64)>, } /// `durable` fsyncs before the rename; the frequent debounced share-save skips it @@ -491,6 +710,33 @@ struct Pool { /// cannot be taken back. Re-derived from the live target on every template /// change, so it also clears by itself once the difficulty recovers. share_halt: Option, + /// Why this pool has stopped moving money, for a reason no template change + /// can clear. + /// + /// `share_halt` above is DERIVED: `recompute_share_target` rebuilds it from + /// the live difficulty on every template change, so it heals by itself and + /// anything written into it is gone within seconds. That is right for a + /// difficulty fall and wrong for a durable-write failure, which does not + /// heal because the pool noticed it. + /// + /// Set when the accounting could not be persisted. It gates the same two + /// things `share_halt` gates - new share credit and fresh settlement - and + /// deliberately does NOT gate the resolution of payouts already in flight, + /// which is money owed to named miners and must still reach them. + /// + /// Not persisted. A restart clears it, and if the write that set it never + /// succeeded then the restart also reads a state file that never learned + /// about the block. That hole closes when the ledger becomes durable; until + /// then the halt message says so in as many words. + accounting_halt: Option, + /// Why the node cannot be trusted to say where the chain is, if so. + /// + /// Derived from the tip's own timestamp on every template cycle, so it heals + /// by itself the moment the node catches up. It has to be computed OUTSIDE + /// the "the template changed" branch, because the whole signature of a node + /// that has stopped following the chain is that nothing changes: it keeps + /// answering, keeps returning the same height, and looks calm. + node_halt: Option, network_target: [u8; 32], /// Cached /query/miner/pending response for the current template, rebuilt /// only when the template changes so a poll never rebuilds it under the lock. @@ -505,6 +751,11 @@ struct Pool { seen: HashSet<(u64, [u8; 32], u32)>, /// Blocks we submitted, awaiting confirmation that they stuck. submitted: Vec<(u64, [u8; 32])>, + /// Backoff schedule for handing a found block to the node. A field rather + /// than a bare constant so a test can drive the real submission path without + /// paying the real sleeps - the block path is the one place where a test + /// that re-implements the loop instead of calling it is worth nothing. + block_submit_delays: &'static [Duration], /// Found blocks whose income is NOT yet safe to distribute. An entry leaves /// only once the chain still holds OUR hash COINBASE_MATURITY_DEPTH blocks /// later, or immediately once the chain shows a different hash there @@ -590,6 +841,15 @@ struct Pool { /// persisted: a restart hands everyone a full bucket, which is the same /// position an honest worker is always in. rates: HashMap, + /// Shares admitted with NO budget spent, because `rates` was full of ids that + /// were all still active and the prune freed nothing. The limiter fails open + /// there on purpose, so this count is the only evidence anywhere that the + /// guard stopped running. Diagnostic, so not persisted: a restart empties + /// `rates`, and the condition either comes back or it does not. + rate_untracked: u64, + /// The `rate_untracked` count when the operator was last told, and the pool + /// clock at that moment. `None` until it has been said once. + rate_open_told: Option<(u64, u64)>, /// Accepted shares waiting to be reported as ONE line. Diagnostic only, so /// not persisted: a restart starts a fresh count and says so. share_log: ShareLog, @@ -827,6 +1087,18 @@ impl Pool { .err(); } + /// Why the pool must not credit a fresh share or settle anything, if so. + /// + /// One accessor for both halts so a third call site cannot be added that + /// consults only one of them. The accounting halt is reported first: it is + /// the one that does not heal, so it is the one an operator has to act on. + fn halt_reason(&self) -> Option<&str> { + self.accounting_halt + .as_deref() + .or(self.node_halt.as_deref()) + .or(self.share_halt.as_deref()) + } + /// Rebuild the derived in-flight total from the payout rows. fn rebuild_inflight(&mut self) { self.inflight_units = self @@ -944,6 +1216,10 @@ impl Pool { /// False means it is submitting faster than the share target says any /// hardware on this chain could FIND shares, which is what a batch of /// withheld shares looks like on the wire. + /// + /// True is NOT the opposite claim. Past `RATE_WORKERS` ids that are all still + /// active this returns true for a worker it is not tracking at all, so true + /// means "not caught by this" and never "inside its budget". fn rate_admits_share(&mut self, worker: &str, now_ms: u64) -> bool { let per_sec = worker_share_rate(pool_core::share_cost_bits(&self.share_target)); let burst = worker_burst(per_sec); @@ -957,6 +1233,13 @@ impl Pool { // Fail OPEN. Refusing an honest miner's work because a bookkeeping // map is full costs it real money; the residence weighting is what // actually decides the split, and it does not depend on this. + // + // Counted, because for as long as this lasts the one thing holding + // back a batch of withheld shares is not running and nothing else + // would say so. ONE integer add: the pool mutex is held here with + // every miner's request behind it, so the LINE is composed on the + // template cycle instead, in `rate_open_notice`. + self.rate_untracked = self.rate_untracked.saturating_add(1); return true; } } @@ -967,6 +1250,44 @@ impl Pool { rate_admits(st, now_ms, per_sec, burst) } + /// The line owed when the per-worker budget has gone open, if one is owed. + /// + /// `rate_admits_share` admits shares it is not tracking once `rates` is full + /// of active ids, and that is deliberate. What is not acceptable is silence: + /// while it lasts, the one thing holding back a batch of withheld shares + /// dumped at a settlement is not running, and only the pool can see it. + /// + /// Called from the template cycle rather than from the share path. That path + /// holds the pool mutex with every miner's request serialized behind it, and + /// a `format!` plus a blocking write to stderr under that lock is exactly the + /// per-share println this pool already had to take back out. + fn rate_open_notice(&mut self, now_ms: u64) -> Option { + let told = match self.rate_open_told { + None => 0, + Some((told, at)) => { + // A clock that steps BACKWARDS (an ntp correction, a resumed VM) + // must not silence a live incident until it catches up, so a + // negative span reads as due. + if now_ms >= at && now_ms - at < RATE_OPEN_REPEAT_MS { + return None; + } + told + } + }; + // Nothing new since the last line, or nothing has happened at all yet. + let more = self.rate_untracked.checked_sub(told).filter(|n| *n > 0)?; + let total = self.rate_untracked; + self.rate_open_told = Some((total, now_ms)); + Some(format!( + "[shares] the per-worker rate limiter is OPEN: {more} more share(s) admitted with no \ + budget spent, {total} in this process. All {RATE_WORKERS} tracked worker slots hold \ + ids that are still active, so a new id has nowhere to be recorded. Those shares are \ + still credited on purpose, because refusing honest work costs a miner real money, \ + but until the flood of worker ids stops nothing is holding back a batch of withheld \ + shares dumped at a settlement." + )) + } + /// Stable per-worker extranonce -> private search space (coinbase miner_nonce). /// The /work protocol is anonymous, so cap the map: past the cap, hand out a /// deterministic extranonce derived from the name instead of storing it, so a @@ -998,6 +1319,12 @@ impl Pool { return None; } let body = json!({ + // Which build's accounting format this is. Read back by + // classify_state_file, which refuses at startup rather than let an + // older build misread a newer file and pay out money it cannot see. + // A file without this key reads as schema 1 - every file written + // before the key existed is exactly that. + "schema": STATE_SCHEMA, "window": PPLNS_WINDOW, // (worker, arrival time in ms). The times are not decoration: without // them a restart would reset every share's age to zero and hand the @@ -1046,15 +1373,21 @@ impl Pool { // income and never add the transaction fees the chain credited // alongside it, which is money paid out at zero confirmations. "fees_counted": e.fees_counted, + // Who was mining when this block was found. Persisted because a + // restart is the ordinary case between a block and its payout, + // roughly eighty minutes later, and this is the only record of + // it: the share window has rolled over many times by then. + "claim": e.claim, })).collect::>(), // The header timestamp currently being served, so a restart inside a // height reproduces the SAME 89 bytes instead of re-stamping them. // Without it a restart silently invalidates every worker's in-flight // scan pass: measured on a rig, a restart at height 350 served a - // stamp 68 seconds later than the one already in flight, and - // /query/miner/notice only signals a HEIGHT change so nothing told - // the workers to reload. Thousands of shares were hashed into - // nothing before their scan passes ended. + // stamp 68 seconds later than the one already in flight, and nothing + // told the workers to reload. A restart RE-STAMPS rather than swaps, + // so the template thread never sees a change and no notice fires: + // the parked-job wake-up covers a reorg, not this. Thousands of + // shares were hashed into nothing before their scan passes ended. "template_stamp": { "height": self.tpl.height, // The parent as well as the height: after a same-height reorg the @@ -1074,30 +1407,22 @@ impl Pool { }) } - fn load_state(&mut self) { - let Ok(txt) = std::fs::read_to_string(&self.state_file) else { - return; - }; - let j: serde_json::Value = match serde_json::from_str(&txt) { - Ok(j) => j, - Err(e) => { - // Never silently wipe accounting: preserve the corrupt file and - // start fresh only after loudly flagging it for the operator. - let bak = format!("{}.corrupt.{}", self.state_file, std::process::id()); - let _ = std::fs::rename(&self.state_file, &bak); - eprintln!( - "[state] file corrupt ({e}); preserved as {bak}, starting with empty accounting" - ); - return; - } - }; + /// Load accounting from an already-classified ledger document. + /// + /// It no longer reads or validates the file: `classify_state_file` did that + /// at startup, BEFORE any wallet could be created, and refused the process + /// outright on anything unreadable. That is why there is no "start empty on a + /// bad file" branch here any more - reaching this function at all means the + /// file is a JSON object this build's schema covers, so a missing key is a + /// real default and never a swallowed error. + fn load_state(&mut self, j: &serde_json::Value) { // A file written before shares were timed carries bare worker ids. Every // one of them is given the same arrival time, one horizon back, so the // window comes back weighing exactly what the older build weighed it at: // a restart must not move money between miners, in either direction. let horizon = self.pplns.horizon_ms(); - let order = parse_share_order(&j, pool_core::now_ms().saturating_sub(horizon)); - let banked = parse_banked_credit(&j); + let order = parse_share_order(j, pool_core::now_ms().saturating_sub(horizon)); + let banked = parse_banked_credit(j); self.pplns = Pplns::restore(PPLNS_WINDOW, horizon, order, banked); self.accepted = j.get("accepted").and_then(|v| v.as_u64()).unwrap_or(0); self.blocks = j.get("blocks").and_then(|v| v.as_u64()).unwrap_or(0); @@ -1147,11 +1472,32 @@ impl Pool { .get("fees_counted") .and_then(|v| v.as_bool()) .unwrap_or(false); + // A file written before the snapshot existed carries no + // `claim`, and that reads as "no snapshot" - never as + // "nobody was mining". The difference matters the day + // this decides a payout: an empty claim must fall back + // to the old behaviour, not pay nobody. + let claim = x + .get("claim") + .and_then(|v| v.as_array()) + .map(|rows| { + rows.iter() + .filter_map(|r| { + let a = r.as_array()?; + Some(( + a.first()?.as_str()?.to_string(), + a.get(1)?.as_u64()?, + )) + }) + .collect() + }) + .unwrap_or_default(); Some(Immature { height, hash, units, fees_counted, + claim, }) }) .collect() @@ -1162,9 +1508,9 @@ impl Pool { // paid" to zero and make the pool report a number that quietly means // something else, so it is restored with everything else and its start // time travels with it. - self.payout_records = parse_payout_records(&j); - self.owed = parse_owed(&j); - self.paid = parse_paid_ledger(&j); + self.payout_records = parse_payout_records(j); + self.owed = parse_owed(j); + self.paid = parse_paid_ledger(j); self.rebuild_inflight(); let owed_units: u64 = self .owed @@ -1248,10 +1594,10 @@ fn bump_bad_streak( if st.count < BAD_STREAK_WARN { return None; } - if let Some(at) = st.warned_at_ms { - if now_ms.saturating_sub(at) < BAD_STREAK_REPEAT_MS { - return None; - } + if let Some(at) = st.warned_at_ms + && now_ms.saturating_sub(at) < BAD_STREAK_REPEAT_MS + { + return None; } st.warned_at_ms = Some(now_ms); Some(st.count) @@ -1284,10 +1630,10 @@ fn bad_streak_message(worker: &str, streak: u64, since_change_ms: u64) -> String if since_change_ms < TEMPLATE_SETTLE_MS { msg.push_str( " The pool has just changed its template or just restarted, which changes the \ - header under every connected worker, and /query/miner/notice signals only a HEIGHT \ - change - so a worker keeps hashing the header it was handed until its current scan \ - pass ends. That alone explains this, it costs only the work already in flight, and \ - it clears by itself. Nothing to do yet.", + header under every connected worker. /query/miner/notice releases a parked rig on \ + that change, but only at the next poll, and a worker keeps hashing the header it \ + was handed until its current scan pass ends. That alone explains this, it costs \ + only the work already in flight, and it clears by itself. Nothing to do yet.", ); } else { msg.push_str( @@ -2215,7 +2561,51 @@ fn main() { )), }; - let client = http_client(); + // Every request this process makes to the node carries the token, because it + // rides on the client rather than on each of the twenty-odd call sites. A + // node bound to anything but loopback REFUSES to serve its API with an empty + // token, so without this a pool in a container network or on a private LAN + // can never reach its node at all. + let wallet_existed = std::path::Path::new(&wallet_file).exists(); + // Read the ledger BEFORE a wallet can be created, and refuse rather than run + // with empty accounting beside one that holds money. + // + // The order matters as much as the check. load_or_create_wallet below will + // WRITE a key file if none is there, so the classification has to happen + // first: a run that is going to refuse must not leave a real-money wallet + // behind for the next start to inherit. Empty accounting beside a funded + // wallet is the failure that pays the current PPLNS window the whole balance + // - every owed debt forgotten, every in-flight payout's signed bytes gone, + // every immature hold-back dropped so a subsidy is distributed at zero + // confirmations. + let state_file_path = pool_state_path(&wallet_file); + let ledger: Option = match classify_state_file(&state_file_path) { + StateFile::Fresh => None, + StateFile::Readable(j) => Some(*j), + StateFile::Unreadable(why) => refuse(&format!( + "REFUSING to start: {why}.\n\ + {}\n\ + This pool will not start with empty accounting: the wallet may hold miners' money, \ + and settling on a blank ledger would pay the current share window the entire \ + balance - forgetting every debt, every payout already in flight, and every block \ + still maturing.\n\ + What to do: restore the accounting file from a backup, or fix why it cannot be \ + read (a permission change, a half-written file, a wrong path). The file has not \ + been touched. If you have genuinely decided to start fresh - the wallet is empty \ + and nothing is owed - move the unreadable file aside by hand and start again.", + if wallet_existed { + format!("A pool wallet already exists at {wallet_file}.") + } else { + format!( + "No wallet exists at {wallet_file} yet, but a broken accounting file is \ + sitting where one belongs, which means this is not the clean first run it \ + looks like." + ) + } + )), + }; + + let client = http_client_with_token(&std::env::var(NODE_API_TOKEN_ENV).unwrap_or_default()); // Two different failures with two different fixes, so they get two different // messages: a node that is not answering at all, and a node that is answering // about a different chain than the one named on the command line. @@ -2235,12 +2625,28 @@ fn main() { // nothing says so: the pool just mines dead work indefinitely. if let Err(e) = verify_chain_params(&client, &node, ¶ms) { refuse(&format!( - "REFUSING to start: {e}\n\ - What to do: pass the chain that node is really on as . If it is a testnet, \ - spell out its own two settings as \ - `testnet::`, copied from that \ - node's hacash.config.ini. Do not work around this: every block the pool found would \ - be thrown away and every miner here would earn nothing." + // The chain-argument advice belongs ONLY to a refusal that is about + // the chain argument. `verify_chain_params` also refuses for reasons + // that have nothing to do with it - a tip too old to mine on, a node + // with no blocks - and appending it there tells an operator to change + // a setting that is already correct, directly contradicting the + // instruction the refusal itself just gave them. Seen in a live run: + // "wait for the node to reach the network tip" followed immediately + // by "pass the chain that node is really on". + // + // Every refusal from that function now carries its own "What to do". + // This one adds the chain hint only when it is the chain that is in + // question. + "REFUSING to start: {e}{}", + if e.contains("difficulty rule mismatch") || e.contains("pre-ASERT") { + "\nWhat to do: pass the chain that node is really on as . If it is a \ + testnet, spell out its own two settings as \ + `testnet::`, copied from \ + that node's hacash.config.ini. Do not work around this: every block the pool \ + found would be thrown away and every miner here would earn nothing." + } else { + "" + } )); } // Bind BEFORE creating a wallet. The commonest first-run mistakes here are a @@ -2257,7 +2663,6 @@ fn main() { )), }; - let wallet_existed = std::path::Path::new(&wallet_file).exists(); // The ONE read of the key file this process makes. Startup is the only place // a wallet may be created or a failure may stop the program, because it is // the only place an operator is watching and no money is in flight yet. @@ -2370,6 +2775,8 @@ fn main() { // `refuse` exits, so reaching here means healthy. Every later template // change re-derives this from the same function. share_halt: None, + accounting_halt: None, + node_halt: None, network_target, pending_cache: String::new(), workers: HashMap::new(), @@ -2380,6 +2787,7 @@ fn main() { orphaned: 0, seen: HashSet::new(), submitted: Vec::new(), + block_submit_delays: BLOCK_SUBMIT_RETRY_DELAYS, immature: Vec::new(), unsaved: 0, state_seq: 0, @@ -2401,9 +2809,13 @@ fn main() { // grounds to say anything about them. tpl_changed_at_ms: pool_core::now_ms(), rates: HashMap::new(), + rate_untracked: 0, + rate_open_told: None, share_log: ShareLog::default(), }; - pool.load_state(); + if let Some(j) = &ledger { + pool.load_state(j); + } if pool.paid.since == 0 { // A state file written before the ledger existed: start counting now // rather than claim a total that reaches back further than it does. @@ -2434,7 +2846,7 @@ fn main() { // The wallet valuation behind every miner's PENDING figure is // refreshed here, on a slow multiple of the template cycle: it is // one extra node call, and it must never happen on a request. - let money = tick % MONEY_REFRESH_CYCLES == 0; + let money = tick.is_multiple_of(MONEY_REFRESH_CYCLES); tick = tick.wrapping_add(1); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { template_cycle(&pool, &client, &node, &payout, ¶ms, money); @@ -2450,10 +2862,20 @@ fn main() { // line is composed under the lock and printed off it: stdout can // block, and every miner request is serialized behind this // mutex. - let due = plock(&pool).share_log.due(pool_core::now_ms()); + let now_ms = pool_core::now_ms(); + let (due, open) = { + let mut g = plock(&pool); + (g.share_log.due(now_ms), g.rate_open_notice(now_ms)) + }; if let Some(line) = due { println!("{line}"); } + // The per-worker budget has stopped running for some shares. Same + // discipline for the same reason: composed under the lock above, + // written here. On stderr, with the other operator warnings. + if let Some(line) = open { + eprintln!("{line}"); + } std::thread::sleep(Duration::from_secs(2)); } }); @@ -2491,6 +2913,20 @@ fn main() { } }; if CONNS.load(Relaxed) >= MAX_CONNS { + // Say so. This used to be a bare `continue`: the socket closed with + // no answer and no line anywhere, and a /submit/miner/success + // carrying a found block died exactly the same way as a port scan. + // The miner cannot tell a refusal from a network fault either, so + // the ONLY evidence that a block was dropped here is this log. + drop_notice( + &mut CONN_DROP_LOG.lock().unwrap_or_else(|e| e.into_inner()), + Instant::now(), + &format!( + "[accept] the connection table is FULL ({MAX_CONNS}); connections are being \ + dropped unanswered. A miner submitting a found block right now would be \ + dropped too, and would see only a closed socket." + ), + ); continue; // drop: s closes as it goes out of scope } let ip = s.peer_addr().ok().map(|a| a.ip()); @@ -2500,6 +2936,20 @@ fn main() { let mut m = per_ip_lock(); let c = m.entry(ip).or_insert(0); if *c >= MAX_PER_IP { + drop(m); + // Also loud, and for a sharper reason: a whole mining FARM behind + // one NAT is one IP here, so this fires on honest fleets as well + // as on abuse, and the operator is the only one who can tell + // which it is. + drop_notice( + &mut CONN_DROP_LOG.lock().unwrap_or_else(|e| e.into_inner()), + Instant::now(), + &format!( + "[accept] {ip} is at its limit of {MAX_PER_IP} connections; further ones \ + are dropped unanswered, including a block submission. If that is one \ + miner, this is abuse; if it is a farm or a NAT, they are ALL behind it." + ), + ); continue; // drop this connection from a noisy IP } *c += 1; @@ -2607,17 +3057,31 @@ fn template_cycle( // blocks_confirmed would over-count against the chain for good. let mut confirmed = Vec::new(); let mut orphaned = Vec::new(); + // Heights where the chain is currently showing somebody else's hash at a + // depth where that means nothing yet. Provisional: said out loud once per + // competing hash, decided by nobody. + let mut contested: Vec<(u64, String)> = Vec::new(); for (h, ours) in &pending { match chain_hash.get(h) { - Some(cur) if *cur == hex::encode(ours) => { - if buried_deep(tip, *h) { - confirmed.push((*h, *ours)); - } - // Not buried yet: keep watching it, a reorg can still flip it. + Some(cur) if *cur == hex::encode(ours) && buried_deep(tip, *h) => { + confirmed.push((*h, *ours)); } + // Not buried yet: keep watching it, a reorg can still flip it. Some(cur) => { - orphaned.push((*h, *ours)); - println!("[reorg] our block {h} orphaned (chain holds {cur})"); + // A competing hash is definitive only under the SAME burial the + // confirm arm demands. At depth zero it is a one-block fork, and + // the common fate of a one-block fork is to flip back. Tallying + // the orphan here used to stop the pool watching the height, so + // a flip-back could never be seen again. + if buried_deep(tip, *h) { + orphaned.push((*h, *ours)); + println!( + "[reorg] our block {h} orphaned (chain holds {cur}, buried \ + {COINBASE_MATURITY_DEPTH} deep)" + ); + } else { + contested.push((*h, cur.clone())); + } } None => {} // node has not stored it yet; keep waiting } @@ -2629,17 +3093,46 @@ fn template_cycle( let mut released: Vec<(u64, [u8; 32])> = Vec::new(); for e in &immature { match chain_hash.get(&e.height) { - Some(cur) if *cur == hex::encode(e.hash) => { + Some(cur) if *cur == hex::encode(e.hash) && buried_deep(tip, e.height) => { + released.push((e.height, e.hash)); + } + // A different hash at our height: that income never lands in the + // balance IF this sticks, so the hold-back would have nothing left + // to hold. But releasing it at depth zero was the defect that paid a + // whole subsidy at no confirmations. A one-block fork usually flips + // back; when it does, the income really is in the wallet, and a + // hold-back released here was gone for good because nothing re-adds + // one. So the release waits for the same burial a confirmation + // needs. The cost of waiting when the orphan is real: those units + // stay held for ~16 blocks during which they were never spendable + // anyway, because the income they describe never arrived. + Some(cur) => { if buried_deep(tip, e.height) { released.push((e.height, e.hash)); + } else { + contested.push((e.height, cur.clone())); } } - // Orphaned: that income never lands in the balance, so there is - // nothing left to hold back. - Some(_) => released.push((e.height, e.hash)), None => {} } } + // Tell the operator a fork is showing without waiting the full burial, and + // without printing it on every two-second cycle: once per competing hash. + contested.sort_unstable(); + contested.dedup(); + let fork_notices = { + let mut st = REORG_WATCH.lock().unwrap_or_else(|e| e.into_inner()); + note_contested_heights(&mut st, &contested) + }; + for (h, cur) in fork_notices { + eprintln!( + "[reorg?] the chain is showing {cur} at height {h} where our block stands. This is \ + PROVISIONAL: nothing has been decided and no money has moved. The hold-back for \ + that block stays held either way; if the competing hash is still there \ + {COINBASE_MATURITY_DEPTH} blocks deep the block will be reported orphaned, and if \ + the chain flips back it will confirm normally." + ); + } // Value the pool wallet OFF the lock, so `/earnings` can answer a PENDING // question without any node call at all. Balance FIRST and the hold-back // after, exactly as settlement does: a block found in between then shows up @@ -2684,6 +3177,11 @@ fn template_cycle( let mut shot = None; let mut degraded: Option = None; let mut resumed = false; + let mut node_degraded: Option = None; + // Reported once per cycle, off the lock, so the comparison never costs a + // miner a moment of the pool mutex. + let mut claim_drift: Option = None; + let mut node_resumed = false; let mut tpl_changed = false; { let mut p = plock(pool); @@ -2697,6 +3195,30 @@ fn template_cycle( } } if let Some(t) = fresh { + // Is the chain this template stands on still moving? + // + // Deliberately OUTSIDE the "the template changed" branch below, + // which is where a check like this wants to be written. The entire + // signature of a node that has stopped following the chain is that + // NOTHING changes: it keeps answering, keeps returning the same + // height, and a check that only runs on a change never runs again. + // + // The tip's own timestamp is the only evidence available here. The + // template's `timestamp` cannot serve: it is derived from the wall + // clock, so on a node stuck a week ago it still reads as now. + let stale = tip_too_old( + t.height.saturating_sub(1), + t.prev_timestamp, + curtimes(), + TIP_STALE_SECS_WHILE_RUNNING, + ); + match (&stale, p.node_halt.is_some()) { + (Some(why), false) => node_degraded = Some(why.clone()), + (None, true) => node_resumed = true, + _ => {} + } + p.node_halt = stale; + // Replace the template when the tip changes: either a new height, or // a same-height reorg (different prev-hash). At the same height and // same prev-hash the timestamp/difficulty are fixed, so keeping the @@ -2748,6 +3270,21 @@ fn template_cycle( p.submitted .retain(|e| !confirmed.contains(e) && !orphaned.contains(e)); if !released.is_empty() { + // A block's income is about to become payable, so this is the moment + // the two models can be compared on real money: who earned it when + // it was found, against who is holding credit now. Reported only - + // the split below is unchanged, and stays unchanged until this has + // been watched on real blocks. + let now_credit = p.pplns.credit(pool_core::now_ms()); + for e in &p.immature { + if released + .iter() + .any(|(h, hx)| *h == e.height && *hx == e.hash) + { + claim_drift = claim_drift + .or_else(|| describe_claim_drift(e.height, e.units, &e.claim, &now_credit)); + } + } // Matched on (height, hash) alone, NEVER on the whole entry: the // settlement thread can fold a block's transaction fees into `units` // between the snapshot above and this lock, and a whole-entry match @@ -2786,6 +3323,25 @@ fn template_cycle( has resumed" ); } + if let Some(why) = node_degraded { + eprintln!( + "[node] STOPPED crediting shares and STOPPED settling: {why}.\n\ + Every rig pointed here is hashing a template built on that tip, and a block found \ + on it would be worth nothing. Check that the node is still following the chain: \ + this deployment's known failure is a history sync that finishes short of the tip \ + and then ignores live blocks until the process is restarted. Crediting and \ + settlement resume by themselves the moment the node catches up." + ); + } + if let Some(drift) = claim_drift { + println!("{drift}"); + } + if node_resumed { + println!( + "[node] the tip is moving again: shares are being credited and settlement has \ + resumed" + ); + } flush_state(shot); } @@ -2865,6 +3421,225 @@ fn note_block_stalls( shout } +/// The `/stats` body, rebuilt at most once per [`STATS_CACHE_MS`]. +/// +/// Everything expensive happens here and nowhere near a miner's request path. +/// Building this walks the whole share window and every banked credit bucket, +/// allocates a String per worker and sorts twice; doing that under the global +/// pool mutex on every request let one unauthenticated poller serialize every +/// share submission - and a found block - behind it. +/// +/// The cache lock is held across the recompute on purpose. It is taken BEFORE +/// the pool mutex and never while holding it, so there is no cycle, and holding +/// it means a burst of requests costs ONE computation while the rest wait for +/// its result rather than each doing their own. +fn stats_body(pool: &Arc>, now: Instant) -> String { + let mut cache = STATS_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((at, body)) = cache.as_ref() + && now.duration_since(*at) < Duration::from_millis(STATS_CACHE_MS) + { + return body.clone(); + } + let ( + height, + difficulty, + accepted, + blocks, + pending, + orphaned, + window, + workers, + credit, + credit_refused, + ) = { + let p = plock(pool); + ( + p.tpl.height, + p.tpl.difficulty, + p.accepted, + p.blocks, + p.submitted.len(), + p.orphaned, + p.pplns.total(), + p.pplns.counts(), + p.pplns.credit(pool_core::now_ms()), + // Read under the SAME lock as the credit table it belongs to: it + // says how much credit is missing from that table. + p.pplns.banked_refused_ms(), + ) + }; + let body = json!({ + "height": height, + "difficulty": difficulty, + "accepted_shares": accepted, + "blocks_confirmed": blocks, + "blocks_pending": pending, + "blocks_orphaned": orphaned, + "share_window": window, + "workers": workers, + // What a settlement would actually split over. `workers` is the raw + // headcount and is for looking at only: paying by it is what lets a + // miner take the whole window with one burst of withheld shares. + "credit": credit, + "credit_note": "milliseconds of share residence: how long each worker's shares \ + have been in the payout window. Payouts are split by this, not by \ + the `workers` headcount.", + // Credit the pool's per-bucket worker cap would not hold, in the same + // milliseconds as the table above. 0 on any honest pool. The cap has to + // stay - it is what stops a flood of invented payout addresses growing + // that map without bound - so the pool cannot keep both the bound and + // that credit. It keeps the number instead: a settlement that paid + // somebody short leaves a mark here rather than none at all. + "credit_refused_ms": credit_refused, + "freshness_note": "rebuilt at most every 2 seconds. This page is for looking at: \ + nothing that moves money reads it, and hbit-pool-payout settles \ + from the pool's own accounting file and from nothing on a network.", + }) + .to_string(); + *cache = Some((now, body.clone())); + body +} + +/// What a block's income would pay under each model, when they disagree. +/// +/// `None` when there is nothing worth an operator's attention: no snapshot to +/// compare against, or the two models would pay the same people the same way. +/// +/// This is the whole of stage one. A block found at T is payable about eighty +/// minutes later and is split over whoever holds credit THEN, so a miner who +/// connected after the block was found is paid out of it and a miner who left +/// before settlement is paid nothing for the work that found it. Changing that +/// moves money between people, so the pool first RECORDS who earned each block +/// and reports what the difference would have been, on real blocks, before +/// either answer becomes the one that pays. +fn describe_claim_drift( + height: u64, + units: u64, + claim: &[(String, u64)], + now: &[(String, u64)], +) -> Option { + if claim.is_empty() { + return None; // found before the snapshot existed: nothing to compare + } + let share = |rows: &[(String, u64)]| -> Vec<(String, u64)> { + let total: u128 = rows.iter().map(|(_, c)| *c as u128).sum(); + if total == 0 { + return Vec::new(); + } + let mut v: Vec<(String, u64)> = rows + .iter() + .map(|(w, c)| (w.clone(), ((*c as u128 * units as u128) / total) as u64)) + .filter(|(_, u)| *u > 0) + .collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v + }; + let frozen = share(claim); + let live = share(now); + if frozen == live { + return None; // the models agree; there is nothing to warn about + } + let render = |v: &[(String, u64)]| -> String { + if v.is_empty() { + return "nobody".to_string(); + } + v.iter() + .take(6) + .map(|(w, u)| format!("{w}={u}")) + .collect::>() + .join(" ") + }; + // Named separately, because it is the number that says whether this matters. + let paid_but_absent: u64 = live + .iter() + .filter(|(w, _)| !claim.iter().any(|(c, _)| c == w)) + .map(|(_, u)| *u) + .sum(); + Some(format!( + "[claim] block {height} ({units} unit(s)) would be split differently by the two models.\n\ + \x20 who earned it, frozen when it was found: {}\n\ + \x20 who is credited now, and who is paid today: {}\n\ + \x20 {paid_but_absent} unit(s) go to miners who were NOT here when this block was \ + found. Nothing has changed: this pool still pays the second line. The first line is \ + recorded so the difference can be seen on real blocks before it decides anything.", + render(&frozen), + render(&live) + )) +} + +/// What `/query/miner/notice` reports, given the template this pool is serving. +/// +/// The CHAIN TIP, which is one below the height being mined, because that is +/// what the fullnode's own `miner_notice` returns and an unmodified miner is +/// built against the fullnode. A miner asks with the height it is mining and +/// reads `answer >= that` as "there is new work"; answering with the template +/// height makes that true on every single reply, and the miner then skips the +/// anti-spin delay it would otherwise take. +/// +/// A pool must not need the miner to be configured differently. This one is +/// used by ordinary Hacash miners that also point at nodes and at other pools, +/// and the only setting a pool is entitled to change is where to connect and +/// which address to credit. +fn notice_height(template_height: u64) -> u64 { + template_height.saturating_sub(1) +} + +/// The job the pool is serving: the height being mined and the parent it builds +/// on. The same pair is the same header bytes, which is the rule the template +/// thread swaps on (`t.height != p.tpl.height || t.prevhash != p.tpl.prevhash`) +/// and the same rule the miner's own `install_block_mining_stuff` uses to decide +/// a job is new. Nothing else in here has to agree with the miner; this does. +type NoticeJob = (u64, Hash); + +/// Should a parked `/query/miner/notice` answer now? +/// +/// `serving` is the job the pool has this instant, `parked_on` the one it had +/// when this long-poll parked, and `want` the height the miner says it is +/// mining. +/// +/// The height test is the fullnode's, and it is the only one a miner reads as +/// new work. The job test covers what that test cannot see: a same-height reorg +/// moves the parent and leaves the height alone, so every rig parked here goes +/// on hashing a header built on an abandoned block. Those shares are not merely +/// wasted. The pool rebuilds every submission from the CURRENT template, so they +/// come back above target and are refused, and the rig collects a bad streak for +/// work this pool handed it. +/// +/// Compared against the job THIS poll parked on, never against a "changed +/// recently" flag. A released rig re-reads /query/miner/pending, comes back and +/// parks on the new job, so one real change releases a given rig once. A flag +/// would release it on every poll for as long as the flag was set, and the only +/// thing between that and a whole fleet hammering the pool is the miner's 200ms +/// floor. That would be worse than the wait it is meant to cure. +fn notice_should_answer(serving: NoticeJob, parked_on: NoticeJob, want: u64) -> bool { + serving.0 > want || serving != parked_on +} + +/// Which contested heights deserve a fresh provisional notice this cycle. +/// +/// `sighted` is every (height, competing hash) currently showing; the state +/// remembers what was last announced per height. A height announces again only +/// when the competing hash CHANGES - a fork extending is the same fork, a new +/// hash is new news. Heights no longer sighted are pruned, so a fork that flips +/// back and later re-forks announces again rather than being remembered as old. +fn note_contested_heights( + state: &mut HashMap, + sighted: &[(u64, String)], +) -> Vec<(u64, String)> { + state.retain(|h, _| sighted.iter().any(|(sh, _)| sh == h)); + let mut shout = Vec::new(); + for (h, cur) in sighted { + let known = state + .get(h) + .is_some_and(|prev| prev.eq_ignore_ascii_case(cur)); + if !known { + state.insert(*h, cur.clone()); + shout.push((*h, cur.clone())); + } + } + shout +} + /// Has the chain stacked COINBASE_MATURITY_DEPTH blocks on top of height `h`? /// A `None` tip means we could not read the chain this cycle, so nothing counts /// as buried: both callers must err towards keeping a block under observation. @@ -2974,11 +3749,17 @@ impl SettleTally { /// Drop a payout hash the node definitively does not hold from the shared /// pending ledger, and put what it was going to pay onto the owed ledger. /// -/// Safe ONLY for a definitive "the node never took it" - `Admission::Missing`, or -/// a non-zero `ret` from the node's own validator. The node inserts into its -/// mempool before it relays, so a transaction it never inserted was never -/// broadcast either and cannot come back from a peer to be paid twice. A TIMEOUT -/// is not that verdict and must never come here. +/// Safe ONLY for a definitive "the node never took it": a non-zero `ret` from +/// the node's own validator, which refuses before `handle_new_tx` reaches either +/// the mempool insert or the peer broadcast, so those bytes cannot come back +/// from a peer to be paid twice. +/// +/// `Admission::Missing` USED to come here and must never come here again. It is +/// not that verdict. It arrives after a ret=0, and on this node a ret=0 already +/// means inserted and relayed, so Missing means the node lost a transaction it +/// had already put on the wire. Dropping the record there destroys the only copy +/// of the signed bytes and the next cycle signs a second transaction for the +/// same miners. A TIMEOUT is not that verdict either. /// /// The rows do not simply return to the pot. They name the miners this chunk was /// for, and the next cycle would otherwise re-split that money over the whole @@ -3037,11 +3818,19 @@ fn fold_block_fees( /// /// A block the node says is NOT on the chain is not a refusal. It credited /// nothing at all, so it has no fees to hold back, and the confirmation loop -/// releases its entry once another block takes that height. +/// releases its entry once another block takes that height. But "no block at a +/// height at or below the node's own tip" is not that answer - it is a node +/// failing to produce a block it must hold - and `block_txs_of` turns exactly +/// that case into a refusal. +/// +/// `tip` is the tip this settlement cycle already proved the node alive with, +/// passed rather than re-read so the fee verdicts are judged against the same +/// chain state as everything else in the cycle. fn count_immature_fees( pool: &Arc>, client: &reqwest::blocking::Client, node: &str, + tip: u64, ) -> Option { // Off the lock: this is one node call per block, plus one per transaction in // it, and every miner request is serialized behind this mutex. @@ -3055,7 +3844,7 @@ fn count_immature_fees( }; let mut counted: Vec<(u64, [u8; 32], u64)> = Vec::new(); for (height, hash) in uncounted { - match block_fees(client, node, height, &hex::encode(hash)) { + match block_fees(client, node, height, &hex::encode(hash), tip) { BlockFees::Counted(fee) => counted.push((height, hash, fee)), BlockFees::NotOnChain => {} BlockFees::Unknown(why) => { @@ -3520,7 +4309,7 @@ fn settle_once(pool: &Arc>) { // miner's last payout would have read "in flight" until the chain recovered, // and a failed chunk's rows would not have reached the owed ledger either. // Nothing under here has valued a wallet or signed anything yet. - let halted = plock(pool).share_halt.clone(); + let halted = plock(pool).halt_reason().map(str::to_string); if let Some(why) = halted { eprintln!( "[settle] payouts already in flight were resolved, but NOTHING FRESH is being \ @@ -3565,7 +4354,7 @@ fn settle_once(pool: &Arc>) { // it happens here rather than on the confirmation loop's own clock so there // is no window in which a block has landed, its fees are in the balance, and // this settlement has not looked for them yet. - let Some(immature_units) = count_immature_fees(pool, &client, &node) else { + let Some(immature_units) = count_immature_fees(pool, &client, &node, tip) else { // The pool knows there is income it cannot value. Miners are told their // pending figure is STALE rather than paid out of a number that is // missing a block's fees. @@ -3611,6 +4400,28 @@ fn settle_once(pool: &Arc>) { // twice for the same window and the miners who were paid nothing would fund // it. let (mut plan, left) = take_owed(&owed, distributable); + // Money the pool is holding for a named miner whose address it cannot pay. + // It is no longer taken off the top - that starved every other miner, every + // cycle, for ever - so nothing is stuck behind it; but nothing pays it + // either, and that must be said out loud rather than left to show up as a + // balance that keeps climbing. + let stuck = unpayable_owed(&owed); + if !stuck.is_empty() { + let total: u64 = stuck.iter().map(|(_, u)| *u).sum(); + eprintln!( + "[settle] {} owed row(s) totalling {total} unit(s) name an address this pool \ + cannot pay, so they are being passed over rather than allowed to consume the \ + balance. They are still on the ledger and no other miner is short because of \ + them. First: {}", + stuck.len(), + stuck + .iter() + .take(3) + .map(|(w, u)| format!("{w} ({u})")) + .collect::>() + .join(", ") + ); + } let owed_now: u64 = plan.iter().map(|(_, u)| *u).sum(); if owed_now > 0 { println!( @@ -3634,13 +4445,55 @@ fn settle_once(pool: &Arc>) { return; } + // Does the reserve actually fund the transactions this plan needs? + // + // The reserve is subtracted ONCE, up in `distributable_units`, while the fee + // is paid PER transaction. Nothing compared the two, so a settlement large + // enough to be cut into more chunks than the reserve covers would sign and + // submit transactions the wallet cannot fund, and the node would refuse the + // tail with no explanation the operator could act on. + // + // The rows that will not fit are NOT dropped and NOT silently re-split next + // cycle over whoever happens to be in the window then. They go on the owed + // ledger, which is the machinery this pool already uses for a chunk that + // failed: named debts to named miners, paid before anything else next time. + // The tail is TRUNCATED and nothing else is touched, which is correct for + // both kinds of row and is why nothing is written here. + // + // A row that came from `owed` is still on the owed ledger: `take_owed` only + // READ it, and `deduct_owed` runs later against the rows a chunk actually + // carried. Truncating it means it is simply not deducted, so it stays a + // named debt and is paid first next cycle, by itself. + // + // A row from the fresh split has not been promised to anyone yet. Its money + // stays in the wallet and is part of next cycle's distributable balance, + // which is exactly what already happens to a share that rounds below the + // dust threshold. + // + // Re-owing the tail was the obvious move and it is wrong: the owed rows are + // still in that ledger, so it would count them twice. + let (fundable, funded_chunks) = reserve_funds_recipients(SETTLE_RESERVE_UNITS); + if plan.len() > fundable { + let wanted = chunks_needed(plan.len()); + let dropped = plan.len() - fundable; + eprintln!( + "[settle] this settlement needs {wanted} transaction(s) but the reserve of \ + {SETTLE_RESERVE_UNITS} unit(s) funds only {funded_chunks}, so the last ones would \ + be refused by the node for want of a fee. Paying the first {fundable} recipient(s) \ + this cycle and leaving {dropped} for the next one. Nobody has lost anything: a \ + debt stays a debt and unsplit income stays in the wallet. Raise the reserve if \ + this repeats." + ); + plan.truncate(fundable); + } + let main = Address::from(*acc.address()); let mut tally = SettleTally::default(); for chunk in plan.chunks(PAYOUT_CHUNK) { // 0.01 HAC network fee, funded by the reserve. Built from the same helper // `/terms` quotes, so the fee a miner is told about is the fee the // transaction carries. - let mut tx = TransactionType2::new_by(main.clone(), chunk_tx_fee(), curtimes()); + let mut tx = TransactionType2::new_by(main, chunk_tx_fee(), curtimes()); // Exactly what this transaction pays, in the order it pays it. Only rows // that made it into the transaction are here: a recipient the pool had to // skip must never appear in anyone's accounting as money in flight. @@ -3751,11 +4604,15 @@ fn settle_once(pool: &Arc>) { continue; } } - // ret=0 only means the API took the bytes. The node validates - // synchronously and then inserts into the mempool on a background task - // whose result it DISCARDS, so a transaction that fails there is - // reported as accepted and simply never exists. Ask the node what it - // actually holds before counting a single unit as sent. + // ret=0 means this node validated the transaction, inserted it into its + // mempool and relayed it to its peers: mint/src/api/submit_transaction.rs + // defaults `async` to false and this pool never sends it, so + // handle_new_tx runs to completion before the answer comes back. + // + // It is still not proof of payment. A mempool is not the chain, and the + // node can lose the transaction afterwards. Ask what it actually holds + // before counting a single unit as sent, and read the answer knowing + // that these bytes are already out there. match verify_admitted(&client, &node, &txhash) { Admission::Held => { println!( @@ -3776,14 +4633,40 @@ fn settle_once(pool: &Arc>) { tally.record(ChunkOutcome::Delivered, pushed, chunk_units); } Admission::Missing => { + // This used to call owe_back_failed_payout, which DELETES the + // record and with it body_hex - the only copy of the signed + // bytes - and put the rows back on the owed ledger. Its stated + // justification was that the node inserts before it relays, so a + // transaction it does not hold was never broadcast and cannot + // come back from a peer. + // + // That is false against this node. mint/src/api/submit_transaction.rs + // reads `async` as false by default and this pool never sends it, + // so the sync path runs, and node/src/core/protocol.rs finishes + // handle_new_tx with txpool.insert_by(...) and THEN + // p2p.broadcast_message(...) before it returns Ok. A ret=0 from + // this node therefore means inserted AND relayed to peers. + // + // So Missing does not mean "never took it". It means "took it, + // relayed it, and no longer has it": a mempool eviction under + // load, or a restart inside this few-second window. Peers may + // still hold those bytes and a miner may still mine them. Sign a + // second transaction for the same rows and both can confirm, and + // the operator pays those miners twice out of the pool wallet. + // + // The bytes stay. `gone_action` keys on them, so a later cycle + // rebroadcasts the SAME transaction rather than signing a new + // one, and the pending ledger keeps this hash so no fresh + // settlement is planned until it resolves either way. eprintln!( - "[settle] payout tx {short} was accepted by the API but the node does NOT \ - hold it ({pushed} recipients, {chunk_units} units): nothing was paid and \ - nothing was relayed. These rows are now OWED and the next cycle pays them \ - before it splits anything else." + "[settle] payout tx {short} was accepted by the node and the node does NOT \ + hold it now ({pushed} recipients, {chunk_units} units). It was relayed \ + before it was lost, so it may still be in the network and it is NOT being \ + re-signed. The signed bytes are kept and rebroadcast; nothing is counted as \ + paid until the chain buries it. No fresh payout is planned while it is \ + unresolved." ); - owe_back_failed_payout(pool, &txhash); - tally.record(ChunkOutcome::Failed, pushed, chunk_units); + tally.record(ChunkOutcome::Unresolved, pushed, chunk_units); } Admission::Unresolved => { eprintln!( @@ -3819,6 +4702,7 @@ fn handle_submission( height: u64, coinbase_nonce: [u8; 32], block_nonce: u32, + peer: &str, ) -> serde_json::Value { // No route may seat an unpayable key in the PPLNS window. The window is a // fixed 4096 shares shared by everyone, so a key that is filtered out at @@ -3835,7 +4719,7 @@ fn handle_submission( } let key = (height, coinbase_nonce, block_nonce); // Phase 1 - brief lock: reject stale/duplicate early and snapshot the inputs. - let (tpl, share_target, network_target, client, node) = { + let (tpl, share_target, network_target, client, node, block_delays) = { let p = plock(pool); if height != p.tpl.height { return json!({"ok":false,"kind":"stale","height":p.tpl.height}); @@ -3849,14 +4733,25 @@ fn handle_submission( p.network_target, p.client.clone(), p.node.clone(), + p.block_submit_delays, ) }; // Phase 2 - no lock: rebuild exactly what the worker hashed and evaluate the // (deliberately slow) x16rs PoW hash without blocking any other request. + // + // Off the pool lock, but NOT unbounded. Every connection is a thread and + // MAX_CONNS is 1024, so without the gate a thousand of these could run at + // once and one unauthenticated client could buy a slow hash per small GET + // until the machine's whole capacity went into verifying garbage. The gate + // makes a submission WAIT, never refuses it: any of them may turn out to be + // a block, and that cannot be known before this hash. let cb = coinbase_with_extranonce(&tpl, &coinbase_nonce); let intro = intro_bytes(&tpl, &cb, block_nonce); - let hash = pool_core::hash_of(tpl.height, &intro); + let hash = { + let _permit = HASH_GATE.enter(peer); + pool_core::hash_of(tpl.height, &intro) + }; if !pool_core::beats(&hash, &share_target) { // The pool never trusts a worker's own header: it rebuilds one from // (height, coinbase_nonce, block_nonce) and hashes THAT, so a worker @@ -3895,10 +4790,8 @@ fn handle_submission( // A solution that beats the NETWORK target is a whole block and is never // dropped for this: it cost a block's work whatever the share target says, // and throwing it away would cost the pool the entire reward. - if !is_block { - if let Some(why) = &p.share_halt { - return json!({"ok": false, "kind": "degraded", "err": why}); - } + if !is_block && let Some(why) = p.halt_reason() { + return json!({"ok": false, "kind": "degraded", "err": why}); } // The rate limiter exists to bound the replay set, not to throw money // away: a submission that beats the NETWORK target is a whole block @@ -3953,11 +4846,27 @@ fn handle_submission( // the packed transactions are opaque bytes and /submit/block does // not report it - so it starts uncounted and settlement reads it // back off the node before it values anything. + // Freeze WHO earned this block, here, under the same lock that + // records the block itself and inside the same durable snapshot. + // It has to be this instant: settlement runs about eighty minutes + // later and the share window will have rolled over many times, so + // there is no way to reconstruct it afterwards. The winning share + // is already in the window - `pplns.record` ran a few lines above - + // so the miner that found the block is counted in its own claim. + // + // This costs a walk of the window under the lock, which the share + // path deliberately never does. A block is rare, this path already + // takes a durable snapshot and submits over the network, and the + // alternative is not knowing who earned it. + // + // NOTHING IS PAID FROM THIS YET. + let claim = p.pplns.credit(at_ms); p.immature.push(Immature { height: solved, hash, units: block_reward_units(solved), fees_counted: false, + claim, }); ( Commit::Block(block_found_line(worker, solved, &hash)), @@ -3968,7 +4877,12 @@ fn handle_submission( // Phase 3b - no lock: persist the accounting (fsync on a block) before the // block goes out, so a crash right after submitting still knows about it. - flush_state(shot); + // + // The settlement path has always honoured this answer and stops on a false. + // The block path threw it away, and a block is the one place where the write + // carries something the pool cannot reconstruct: the hold-back that keeps + // the next settlement from distributing a whole subsidy at 0 confirmations. + let durable = flush_state(shot); match commit { Commit::Share { accepted, line } => { @@ -3986,42 +4900,147 @@ fn handle_submission( Commit::Block(notice) => println!("{notice}"), } + // Only a block reaches here, and only a block makes this fatal. + // + // The block is still submitted below. It is irreplaceable, the chain does + // not care what this pool managed to write to disk, and refusing to submit + // would turn a bookkeeping failure into a certain loss of the whole reward. + // What stops instead is the movement of money. + if !durable { + let why = format!( + "the accounting could not be written to disk when block {height} was found, so \ + that block's hold-back exists in memory only" + ); + eprintln!( + "[block] ACCOUNTING HALTED. {why}. The block itself is being submitted normally. \ + No new share will be credited and no fresh payout will be planned until this pool \ + is restarted with a writable state file; payouts already in flight keep resolving. \ + Fix the disk first: a restart BEFORE the write succeeds reads a state file that \ + never learned about height {height}, and the pool would then distribute that \ + block's income at 0 confirmations." + ); + plock(pool).accounting_halt = Some(why); + } + // Phase 4 - no lock: serialize and submit the winning block. This is where // the node's packed transactions are carried into the block: OUR coinbase in // slot 0, then every transaction the node packed for this height, with a // merkle root folded from the node's own sibling list. let block_bytes = assemble_block(&tpl, &cb, block_nonce); let packed = tpl.txs.bodies.len(); - let submit = submit_block_bytes(&client, &node, &block_bytes); + let size = block_bytes.len(); // The submit answer used to go only into the JSON the winning worker reads, // so an outright refusal - a whole block reward - never reached the operator. - if block_submit_refused(&submit) { - eprintln!( - "[block] the node REFUSED our block at height {height} ({packed} packed tx(s), {} \ - bytes): {submit}. That block's entire reward is lost.", - block_bytes.len() - ); - } else { - println!( - "[block] submitted height {height} carrying {packed} packed tx(s) ({} bytes): \ - {submit}", - block_bytes.len() - ); + let (verdict, submit, attempts) = submit_block_with_retries( + || submit_block_bytes(&client, &node, &block_bytes), + block_delays, + ); + match verdict { + BlockSubmitVerdict::Queued => println!( + "[block] submitted height {height} carrying {packed} packed tx(s) ({size} bytes) on \ + attempt {attempts}: {submit}" + ), + BlockSubmitVerdict::Refused => eprintln!( + "[block] the node REFUSED our block at height {height} ({packed} packed tx(s), \ + {size} bytes): {submit}. That block's entire reward is lost." + ), + // Deliberately not called a loss. Nobody refused this block; the pool + // could not read an answer. It may well be on the chain, and saying + // "lost" here would teach an operator to distrust the line that IS a + // loss. `note_block_stalls` reports in about two minutes if the tip + // never reaches this height. + BlockSubmitVerdict::Unresolved => eprintln!( + "[block] UNRESOLVED at height {height} after {attempts} attempt(s) ({packed} packed \ + tx(s), {size} bytes): {submit}. The node gave no readable verdict, so this block \ + may or may not have landed. Watch for a stall warning on this height." + ), } - json!({"ok":true,"kind":"block","solved_height":height,"submit":submit}) + json!({ + "ok": true, + "kind": "block", + "solved_height": height, + "submit": submit, + "verdict": match verdict { + BlockSubmitVerdict::Queued => "queued", + BlockSubmitVerdict::Refused => "refused", + BlockSubmitVerdict::Unresolved => "unresolved", + }, + "attempts": attempts, + }) } -/// Did `/submit/block` refuse the block outright? +/// What `/submit/block` said about our block. /// -/// The node validates asynchronously, so `ret:0` means only "parsed and queued" -/// and is NOT proof of acceptance - `note_block_stalls` is what catches a later -/// silent refusal. But `ret:1`, a transport failure, or an unparseable answer -/// are definitive, and each one costs a whole block reward. -fn block_submit_refused(resp: &str) -> bool { +/// This used to be a bool, and the state it was missing cost blocks. A timeout, +/// a proxy's HTML error page and an empty body all failed to parse, and the one +/// bool reported all three to the operator as "that block's entire reward is +/// lost" - when the node may never have seen the bytes at all, and a second +/// attempt would have landed it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockSubmitVerdict { + /// The node parsed the block and queued it. Validation on this endpoint is + /// asynchronous (`/submit/block` hardcodes it), so this is NOT proof the + /// block stuck; `note_block_stalls` is what catches a later silent refusal. + Queued, + /// The node's own answer said no. Resubmitting identical bytes earns an + /// identical answer, so this is the one verdict that is worth no retry. + Refused, + /// Nothing is known. Transport failure, timeout, proxy error page, empty + /// body, or JSON carrying no `ret`. The block may or may not be at the node. + /// This must never be reported as a loss and must never end the retries. + Unresolved, +} + +/// Read one `/submit/block` answer. +fn classify_block_submit(resp: &str) -> BlockSubmitVerdict { match serde_json::from_str::(resp) { - Ok(j) => find_u64(&j, "ret") != Some(0), - Err(_) => true, + Ok(j) => match find_u64(&j, "ret") { + Some(0) => BlockSubmitVerdict::Queued, + Some(_) => BlockSubmitVerdict::Refused, + // Parsed as JSON but carries no verdict at all. Reading that as a + // refusal is exactly what turned a proxy's JSON error body into a + // "reward lost" line for a block nobody had refused. + None => BlockSubmitVerdict::Unresolved, + }, + Err(_) => BlockSubmitVerdict::Unresolved, + } +} + +/// Hand a found block to the node, retrying for as long as the answer says +/// nothing. Returns the verdict, the last answer seen, and how many attempts it +/// took, so the operator log can distinguish "refused" from "never got through". +/// +/// Only the FIRST attempt's refusal is taken as definitive. A refusal that +/// arrives after an unresolved attempt is ambiguous: the most likely reason a +/// node refuses a block it did not refuse a moment ago is that it already holds +/// it, and reporting that as a lost reward would be a false alarm on the one +/// event an operator has to be able to trust. +fn submit_block_with_retries( + submit: impl Fn() -> String, + delays: &[Duration], +) -> (BlockSubmitVerdict, String, u32) { + let mut last = submit(); + let first = classify_block_submit(&last); + if first != BlockSubmitVerdict::Unresolved { + return (first, last, 1); + } + let mut attempts = 1u32; + for delay in delays { + std::thread::sleep(*delay); + let resp = submit(); + attempts += 1; + match classify_block_submit(&resp) { + BlockSubmitVerdict::Queued => return (BlockSubmitVerdict::Queued, resp, attempts), + // Ambiguous, see above: keep the answer for the log, keep the + // verdict unresolved, and stop - the node has spoken twice now and + // another identical POST is not going to say anything new. + BlockSubmitVerdict::Refused => { + return (BlockSubmitVerdict::Unresolved, resp, attempts); + } + BlockSubmitVerdict::Unresolved => last = resp, + } } + (BlockSubmitVerdict::Unresolved, last, attempts) } /// Should the per-height share rate limiter refuse this submission? @@ -4157,8 +5176,73 @@ fn route( ) -> String { match path { // ---- standard Hacash miner API: an UNMODIFIED poworker mines here ---- - "/query/miner/pending" => plock(pool).pending_cache.clone(), + // + // A halted pool says so HERE, in the words the miner already understands. + // + // While `halt_reason()` is set the pool credits no new share, so a rig + // that keeps hashing is burning power for nothing. It used to keep + // serving the cached template regardless, and the miner had no way to + // know: the submit path answers `{"ret":1,"kind":"degraded"}` and drops + // the reason, and `pool_kind_verdict` has no arm for that kind at all. + // + // poworker already knows how to stop. `upstream_stale_reason` reads an + // `err` containing "stale" from this endpoint and from the notice, and + // pauses the mining threads until work returns. Saying it that way means + // every miner ALREADY RELEASED does the right thing with no update and + // no per-pool setting: a pool is entitled to change where a miner + // connects and which address it credits, not how the miner is built. + // + // A block is still accepted throughout, so nothing is lost by pausing: + // during an accounting halt the pool cannot record who earned it, during + // a node halt the tip is dead anyway, and during a difficulty halt credit + // means nothing. + "/query/miner/pending" => { + let p = plock(pool); + match p.halt_reason() { + Some(why) => json!({ + "ret": 1, + "err": format!("the pool is serving stale work and is crediting nothing: {why}") + }) + .to_string(), + None => p.pending_cache.clone(), + } + } + // Answers the TIP, exactly as the fullnode's own miner_notice does, and + // not the template height. + // + // This pool is not what a miner is built for: an unmodified poworker is a + // general Hacash miner that must behave the same way here as against any + // node or any other pool. It asks with `height` set to the height it is + // MINING - the tip plus one - and treats `answer >= that` as "new work + // exists". The node returns `latest_block().height()`, so that comparison + // is false until a block really arrives, and poworker's 200ms anti-spin + // floor applies. + // + // Returning `tpl.height` made it ALWAYS true. Whenever this endpoint + // answered without parking - which is what it does when too many + // long-polls are already waiting, so precisely under load - the miner saw + // "new work", skipped its floor, and came straight back. Two requests per + // cycle with no delay, from every rig, exactly when the pool is already + // shedding. + // + // The parking condition keeps that height test and adds a second one. + // Waiting for `tpl.height > want` is waiting for the tip to reach + // `want`, and a SAME-height reorg never makes it true: the parent moves + // and the height does not. Every rig parked here used to go on hashing a + // header built on an abandoned block for the rest of the long-poll. See + // `notice_should_answer`; the reply reports the tip either way, so what + // an unmodified miner is told about new work does not change at all. "/query/miner/notice" => { + // Same answer as /query/miner/pending, because the miner reads BOTH + // for it and a pool that pauses on one and not the other would park + // a rig for the full long-poll before it learned anything. + if let Some(why) = plock(pool).halt_reason() { + return json!({ + "ret": 1, + "err": format!("the pool is serving stale work and is crediting nothing: {why}") + }) + .to_string(); + } let want: u64 = params .get("height") .and_then(|v| v.parse().ok()) @@ -4172,15 +5256,26 @@ fn route( // immediately with the current height rather than holding another slot. if NOTICE_WAITERS.fetch_add(1, Relaxed) >= MAX_NOTICE_WAITERS { NOTICE_WAITERS.fetch_sub(1, Relaxed); - let h = plock(pool).tpl.height; - return json!({"ret":0,"height":h}).to_string(); + return json!({"ret":0,"height":notice_height(plock(pool).tpl.height)}).to_string(); } let _ng = NoticeGuard; let deadline = Instant::now() + Duration::from_secs(wait); + // The job this rig is hashing, as closely as the pool can know it: a + // miner reads /query/miner/pending and parks here immediately after, + // and the notice request carries a height and nothing else, never a + // parent. There is no better source and there cannot be one without + // a modified miner. + let parked_on: NoticeJob = { + let p = plock(pool); + (p.tpl.height, p.tpl.prevhash) + }; loop { - let h = plock(pool).tpl.height; // brief lock only - if h > want || Instant::now() >= deadline { - return json!({"ret":0,"height":h}).to_string(); + let serving: NoticeJob = { + let p = plock(pool); // brief lock only + (p.tpl.height, p.tpl.prevhash) + }; + if notice_should_answer(serving, parked_on, want) || Instant::now() >= deadline { + return json!({"ret":0,"height":notice_height(serving.0)}).to_string(); } std::thread::sleep(Duration::from_millis(400)); } @@ -4211,8 +5306,10 @@ fn route( }) .to_string(); }; - let _ = peer; // no longer used for attribution on the paid path - let r = handle_submission(pool, &worker, height, cn, block_nonce); + // Not attribution - credit comes from `worker` and nothing else. + // This is the share of the verification gate this source may hold, + // so one host cannot spend the whole machine on hashing garbage. + let r = handle_submission(pool, &worker, height, cn, block_nonce, peer); let ok = r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); let kind = r.get("kind").and_then(|v| v.as_str()).unwrap_or(""); // Nothing is printed here any more. This route used to print one @@ -4225,7 +5322,12 @@ fn route( if ok { json!({"ret":0,"kind":kind}).to_string() } else { - json!({"ret":1,"kind":kind}).to_string() + // The REASON travels with the refusal. It used to be dropped + // here, so a miner refused for a halted pool, a stale template + // or an unpayable address saw the same bare `kind` and its + // operator had nothing to act on. + let err = r.get("err").and_then(|v| v.as_str()).unwrap_or(""); + json!({"ret":1,"kind":kind,"err":err}).to_string() } } @@ -4290,47 +5392,9 @@ fn route( } } }; - handle_submission(pool, &worker, height, en, nonce).to_string() - } - "/stats" => { - // Copy the numbers out, then RELEASE the lock before building the - // body. /stats is open and unauthenticated, and serializing up to - // PPLNS_WINDOW worker rows under the global mutex would let anyone - // stall every miner's /work, /share and /submit by polling it. - let (height, difficulty, accepted, blocks, pending, orphaned, window, workers, credit) = { - let p = plock(pool); - ( - p.tpl.height, - p.tpl.difficulty, - p.accepted, - p.blocks, - p.submitted.len(), - p.orphaned, - p.pplns.total(), - p.pplns.counts(), - p.pplns.credit(pool_core::now_ms()), - ) - }; - json!({ - "height": height, - "difficulty": difficulty, - "accepted_shares": accepted, - "blocks_confirmed": blocks, - "blocks_pending": pending, - "blocks_orphaned": orphaned, - "share_window": window, - "workers": workers, - // What a settlement would actually split over. `workers` is the - // raw headcount and is for looking at only: paying by it is what - // lets a miner take the whole window with one burst of withheld - // shares. hbit-pool-payout reads THIS. - "credit": credit, - "credit_note": "milliseconds of share residence: how long each worker's shares \ - have been in the payout window. Payouts are split by this, not by \ - the `workers` headcount.", - }) - .to_string() + handle_submission(pool, &worker, height, en, nonce, peer).to_string() } + "/stats" => stats_body(pool, Instant::now()), // The pool's terms, READ OUT OF the code that enforces them. Nothing here // is a number somebody typed into a description: change what the pool @@ -4353,7 +5417,7 @@ fn route( p.share_factor, p.share_factor_achieved, p.share_cost_bits, - p.share_halt.clone(), + p.halt_reason().map(str::to_string), p.tpl.difficulty, p.settle_secs, ) @@ -4521,7 +5585,7 @@ mod tests { assert_eq!(refreshed_money(&refused, 0), None); // This is the line template_cycle runs on the result: `None` leaves the // last figure the pool could stand behind in place and marks it STALE. - assert!(!refreshed_money(&down, 0).is_some()); + assert!(refreshed_money(&down, 0).is_none()); // A wallet the node really reports as empty is a DIFFERENT answer, and // the pool must keep standing behind it. Refusing "0:0" as unreadable @@ -4731,7 +5795,7 @@ mod tests { // B's chunk failed: its rows become a debt to B, not money in the pot. let mut owed: Vec<(String, u64)> = Vec::new(); - owe_rows(&mut owed, &[owed_to_b.clone()]); + owe_rows(&mut owed, std::slice::from_ref(&owed_to_b)); assert_eq!(owed, vec![(W_B.to_string(), 50)]); // Next cycle. A's 50 really left the wallet, so 50 is distributable and @@ -4764,10 +5828,497 @@ mod tests { } #[test] - fn an_owed_row_survives_a_restart_and_outlives_the_share_window() { - // The debt is only as good as the file it lives in: a pool restarted - // between the failed chunk and the next settlement would otherwise forget - // it entirely, and the money would quietly rejoin the pot. + fn a_halted_pool_tells_the_miner_to_stop_in_words_it_already_understands() { + // A halted pool credits no new share, so a rig that keeps hashing burns + // power for nothing. It used to keep serving the cached template, and + // the miner could not tell: the submit path answers "degraded" and + // poworker has no arm for that kind at all. + // + // poworker DOES already stop on an `err` containing "stale", read from + // /query/miner/pending and /query/miner/notice. Saying it that way means + // every already-released miner does the right thing with no update. + let mut p = a_pool(); + p.pending_cache = r#"{"ret":0,"height":771594}"#.to_string(); + p.accounting_halt = Some("the accounting could not be written to disk".to_string()); + let pool = Arc::new(Mutex::new(p)); + + for path in ["/query/miner/pending", "/query/miner/notice"] { + let body = route(path, &HashMap::new(), &pool, "test-peer"); + let j: serde_json::Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("{path} must answer JSON: {e} in {body}")); + assert_eq!(j["ret"].as_i64(), Some(1), "{path}: {body}"); + let err = j["err"].as_str().unwrap_or_default(); + + // THE test: poworker's own detector, quoted from app/src/poworker.rs + // let err = res["err"].as_str()?; + // if err.to_ascii_lowercase().contains("stale") { Some(err) } + assert!( + err.to_ascii_lowercase().contains("stale"), + "{path} must trip the miner's existing pause, which keys on the \ + word this reason has to carry: {err}" + ); + assert!( + err.contains("could not be written to disk"), + "and the operator has to be told WHICH halt: {err}" + ); + assert!( + !body.contains("771594"), + "{path} must not keep handing out work it will credit nothing for: {body}" + ); + } + + // Healthy again: the template comes back, unchanged. + plock(&pool).accounting_halt = None; + let body = route("/query/miner/pending", &HashMap::new(), &pool, "test-peer"); + assert!(body.contains("771594"), "work resumes by itself: {body}"); + } + + #[test] + fn the_notice_endpoint_speaks_the_same_height_the_fullnode_does() { + // A miner is not built for this pool. An unmodified poworker also points + // at nodes and at other pools, and it must behave identically at all of + // them: it asks with the height it is MINING and reads `answer >= that` + // as "new work exists". + // + // The fullnode answers with its tip, one BELOW the height being mined, + // so that comparison is false until a block really arrives and the + // miner's 200ms anti-spin floor applies. This pool answered with the + // template height, making it true on every reply - so the floor was + // skipped every time, and worst when the pool answers without parking, + // which is what it does once too many long-polls are already waiting. + let tip = 771_596u64; + let template = tip + 1; // what the pool is serving work for + assert_eq!( + notice_height(template), + tip, + "the notice reports the tip, exactly as mint's miner_notice does" + ); + + // The miner's own condition, quoted from poworker: + // new_work_ready = pending_height > 0 && res_hei >= pending_height + let pending_height = template; // the height the miner is mining + assert!( + notice_height(template) < pending_height, + "no new work while the chain is still at the same tip: the miner must \ + take its anti-spin delay" + ); + // A block arrives: the pool's template moves up, and only then is the + // miner told there is work. + assert!( + notice_height(template + 1) >= pending_height, + "once the chain moves, the miner is released immediately: new work is \ + money and is never delayed" + ); + + // An empty chain must not underflow into a colossal height. + assert_eq!(notice_height(0), 0); + } + + #[test] + fn a_same_height_reorg_releases_a_parked_notice_long_poll() { + // A same-height reorg replaces the parent and leaves the height alone, + // so the tip test the fullnode uses can never fire for it. Every rig + // parked in this long-poll went on hashing a header built on an + // abandoned block until the poll timed out, and every share it found + // was then rebuilt by the pool against the NEW parent, came out above + // target, was refused, and counted against the rig as a bad streak: it + // was marked bad for doing exactly what this pool told it to do. + // + // Drive the ENDPOINT, not the predicate. A test on the predicate alone + // stays green when the loop is reverted, which is the whole defect. + let p = a_pool(); + let height = p.tpl.height; + let pool = Arc::new(Mutex::new(p)); + + let mut params = HashMap::new(); + params.insert("height".to_string(), height.to_string()); + params.insert("wait".to_string(), "3".to_string()); + + // Nothing has moved: the poll must HOLD. If it answers here the endpoint + // is not parking at all and everything below would prove nothing. + let held = Arc::clone(&pool); + let hp = params.clone(); + let t0 = Instant::now(); + let h = std::thread::spawn(move || route("/query/miner/notice", &hp, &held, "test-peer")); + std::thread::sleep(Duration::from_millis(400)); + assert!(!h.is_finished(), "an unchanged job must hold the long-poll"); + let body = h.join().expect("the long-poll thread"); + assert!( + t0.elapsed() >= Duration::from_secs(3), + "it must wait out the poll" + ); + let j: serde_json::Value = serde_json::from_str(&body).expect("json"); + assert_eq!(j["height"].as_u64(), Some(height - 1)); + + // Now the reorg: same height, different parent. + let woken = Arc::clone(&pool); + let wp = params.clone(); + let t1 = Instant::now(); + let w = std::thread::spawn(move || route("/query/miner/notice", &wp, &woken, "test-peer")); + std::thread::sleep(Duration::from_millis(500)); // let it park and snapshot + plock(&pool).tpl.prevhash = Hash::from([0xb2u8; 32]); + let body = w.join().expect("the long-poll thread"); + assert!( + t1.elapsed() < Duration::from_secs(2), + "a same-height reorg is dead work and must release the rig, not \ + leave it hashing an abandoned parent for the rest of the poll" + ); + + // ...and the answer is still the fullnode's, the tip, so an unmodified + // miner does NOT read it as new work. Quoted from poworker: + // new_work_ready = pending_height > 0 && res_hei >= pending_height + // It takes its 200ms floor and re-reads /query/miner/pending, which is + // where the fresh parent is. + let j: serde_json::Value = serde_json::from_str(&body).expect("json"); + assert_eq!( + j["height"].as_u64(), + Some(height - 1), + "a released rig is not told there is new work" + ); + + // The height half is load-bearing too: a miner asking about a height the + // pool has already passed is answered at once, never parked. + let mut stale = HashMap::new(); + stale.insert("height".to_string(), (height - 1).to_string()); + stale.insert("wait".to_string(), "3".to_string()); + let t2 = Instant::now(); + let _ = route("/query/miner/notice", &stale, &pool, "test-peer"); + assert!( + t2.elapsed() < Duration::from_secs(1), + "a lagging height answers at once" + ); + } + + #[test] + fn a_dropped_connection_is_announced_without_flooding_the_log() { + // Both connection-table refusals were a bare `continue`: the socket + // closed with no answer and no line anywhere. A /submit/miner/success + // carrying a found block died exactly like a port scan, and the miner + // could not tell a refusal from a network fault either - so nothing + // recorded that a block had been dropped. + // + // It cannot be unconditional either: a full table fires on every accept, + // and a line per accept scrolls its own explanation away during the + // incident it describes. + let mut st: Option<(String, Instant)> = None; + let t0 = Instant::now(); + let full = "[accept] the connection table is FULL"; + let noisy = "[accept] 10.0.0.7 is at its limit"; + + drop_notice(&mut st, t0, full); + assert_eq!(st.as_ref().map(|(s, _)| s.as_str()), Some(full)); + + // The same cause a moment later stays quiet. + drop_notice(&mut st, t0 + Duration::from_secs(1), full); + assert_eq!(st.as_ref().map(|(_, at)| *at), Some(t0), "not reprinted"); + + // A DIFFERENT cause is not silenced by the first: they have different + // fixes, and an operator needs to know which is happening. + drop_notice(&mut st, t0 + Duration::from_secs(2), noisy); + assert_eq!(st.as_ref().map(|(s, _)| s.as_str()), Some(noisy)); + + // And the same cause repeats once the interval has passed, so a lasting + // incident keeps saying so. + drop_notice( + &mut st, + t0 + Duration::from_secs(2) + CONN_DROP_EVERY, + noisy, + ); + assert_eq!( + st.as_ref().map(|(_, at)| *at), + Some(t0 + Duration::from_secs(2) + CONN_DROP_EVERY), + "a continuing incident is repeated, not silently forgotten" + ); + } + + #[test] + fn a_settlement_the_reserve_cannot_fund_is_cut_down_and_nothing_is_lost() { + // B5, through the real settle_once. The reserve is subtracted ONCE while + // the network fee is paid PER transaction, and nothing compared them: a + // plan cut into more chunks than the reserve funds signed transactions + // the wallet could not pay for, and the node refused the tail. + // + // What must be true after the cut: the debts that did not fit are STILL + // debts. Re-owing them here was the obvious move and would have counted + // them twice, because take_owed only reads the ledger - deduct_owed is + // what removes a row, and it runs against the rows a chunk carried. + let (fundable, _) = reserve_funds_recipients(SETTLE_RESERVE_UNITS); + let over = fundable + 25; + + let (node, seen) = a_stub_node_answering(vec![ + ( + "/query/balance", + r#"{"ret":0,"list":[{"hacash":"90000:248"}]}"#, + ), + ("/submit/transaction", r#"{"ret":0}"#), + ("/query/transaction", r#"{"ret":0,"pending":true}"#), + ]); + let mut p = a_pool(); + p.node = node; + p.matured = Some(Matured { + units: 900_000, + at: 1_500, + }); + // More named debts than the reserve can fund transactions for. Real + // payable addresses, or the payable filter would remove them for an + // unrelated reason and the test would prove nothing. + let owed: Vec<(String, u64)> = (0..over) + .map(|i| (a_wallet_address(i as u64), 1u64)) + .collect(); + p.owed = owed.clone(); + let pool = Arc::new(Mutex::new(p)); + + settle_once(&pool); + + let g = plock(&pool); + let still_owed: u64 = g.owed.iter().map(|(_, u)| *u).sum(); + // EXACTLY the tail, in units and not in rows. `owe_rows` merges by + // address, so re-owing a deferred row does not add a row - it doubles an + // amount. Counting rows misses that entirely, which is how the first + // version of this test passed against the very mistake it exists to + // catch: the tail being written back onto a ledger it had never left. + assert_eq!( + still_owed, + (over - fundable) as u64, + "the debts that did not fit must still be owed, once each: {} row(s) totalling \ + {still_owed} unit(s), from {over} rows of 1 unit with {fundable} funded", + g.owed.len() + ); + // The node was asked to take transactions, so the cut did not turn into + // "pay nobody" - which would be a permanent freeze rather than a fix. + let asked = seen.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert!( + asked.iter().any(|p| p.starts_with("/submit/transaction")), + "the funded part still had to be paid: {asked:?}" + ); + } + + #[test] + fn a_found_block_records_who_earned_it_and_still_pays_the_old_way() { + // A1, stage one. The pool now writes down who was mining when a block + // was found. Nothing is paid from it: this test pins BOTH halves, because + // "we recorded it" and "we changed who gets paid" are very different + // promises and only the first one is being made yet. + let mut p = a_pool(); + p.network_target = [0xff; 32]; // this submission is a block + let now = pool_core::now_ms(); + // Two miners with real residence, so the frozen claim is not trivial. + p.pplns.record(W_A, now.saturating_sub(60_000)); + p.pplns.record(W_B, now.saturating_sub(20_000)); + let height = p.tpl.height; + let pool = Arc::new(Mutex::new(p)); + + let r = handle_submission(&pool, W_A, height, [0x91u8; 32], 1, "test-peer"); + assert_eq!(r["kind"].as_str(), Some("block"), "{r}"); + + let g = plock(&pool); + let e = g.immature.first().expect("the block is held back"); + assert!( + !e.claim.is_empty(), + "a found block has to record who earned it: the window will have rolled over \ + many times by the time this money is payable, and there is no way to \ + reconstruct it afterwards" + ); + assert!( + e.claim.iter().any(|(w, _)| w == W_A), + "including the miner that found it: its share is already in the window" + ); + assert!( + e.claim.iter().any(|(w, _)| w == W_B), + "and everyone else who was mining at that instant: {:?}", + e.claim + ); + } + + #[test] + fn the_two_payout_models_are_compared_but_only_one_of_them_pays() { + // The report is the whole of stage one, so it has to be right about + // WHICH model is in force. Saying it the wrong way round would tell an + // operator their pool had changed when it had not. + let claim = vec![(W_A.to_string(), 100u64)]; + // Nobody from the claim is still here: the money goes entirely to a + // miner who was not present when the block was found. This is the case + // the whole change exists for. + let now = vec![(W_B.to_string(), 100u64)]; + + let drift = describe_claim_drift(800_000, 10, &claim, &now) + .expect("two models that pay different people must be reported"); + assert!(drift.contains(W_A), "the earner is named: {drift}"); + assert!(drift.contains(W_B), "and who is paid today: {drift}"); + assert!( + drift.contains("10 unit(s) go to miners who were NOT here"), + "the number that says whether it matters: {drift}" + ); + assert!( + drift.contains("still pays the second line"), + "and it must be unmistakable that nothing has changed yet: {drift}" + ); + + // Agreement is silence: an operator must not be trained to skim this. + assert!(describe_claim_drift(800_000, 10, &claim, &claim).is_none()); + // No snapshot is not a disagreement. Blocks found before this existed + // have nothing to compare against, and reporting them as drift would be + // inventing a fact. + assert!(describe_claim_drift(800_000, 10, &[], &now).is_none()); + } + + #[test] + fn one_source_cannot_take_the_whole_verification_gate() { + // A8. Verifying a submission is deliberately slow and nothing bounded + // how many ran at once: every connection is a thread, MAX_CONNS is 1024, + // and an unauthenticated client bought one slow hash per small GET. At + // scale the machine's whole capacity went into verifying garbage while + // an honest miner's share, and the winning worker's block, waited. + // + // Bounding the TOTAL alone would not have been enough: one IP is allowed + // MAX_PER_IP = 24 connections, so it could still hold every permit. The + // share is what keeps honest miners moving. + let gate = HashGate::new(8); + assert_eq!(gate.total, 8); + assert_eq!(gate.per_peer, 2, "a quarter of the machine, not all of it"); + + let noisy: Vec> = + (0..gate.per_peer).map(|_| gate.enter("10.0.0.1")).collect(); + { + let st = gate.state.lock().expect("gate"); + assert_eq!(st.0, gate.per_peer); + } + // That source is now at its share. An honest miner from elsewhere must + // still get in immediately - this is the whole point. + let honest = gate.enter("10.0.0.2"); + { + let st = gate.state.lock().expect("gate"); + assert_eq!(st.0, gate.per_peer + 1, "the honest source was admitted"); + } + + // The noisy source asking for one more must WAIT, not be refused: a + // submission that waits is still verified, and any of them may be a + // block. Proven by the fact that it only completes once a permit is + // released. + let done = Arc::new(AtomicUsize::new(0)); + let g = &gate; + std::thread::scope(|s| { + let flag = done.clone(); + s.spawn(move || { + let _p = g.enter("10.0.0.1"); + flag.fetch_add(1, Relaxed); + }); + // Give the waiter a moment to prove it is blocked rather than slow. + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + done.load(Relaxed), + 0, + "a source over its share must wait for room" + ); + drop(noisy); // release the share + }); + assert_eq!(done.load(Relaxed), 1, "and then it proceeds, never refused"); + + drop(honest); + let st = gate.state.lock().expect("gate"); + assert_eq!(st.0, 0, "every permit is released, including on scope exit"); + assert!( + st.1.is_empty(), + "and the per-source table does not leak rows" + ); + } + + #[test] + fn polling_stats_cannot_hold_the_lock_every_miner_needs() { + // A7. Building this body walks the whole share window and every banked + // bucket, allocates a String per worker and sorts twice. That used to + // happen under the global pool mutex on EVERY request, so one + // unauthenticated poller inside its per-IP allowance could serialize + // every miner's share submission behind it - and a found block needs + // that same mutex. + // + // The proof is direct: the pool lock is HELD for the whole of the second + // call, so if that call needed it at all it would deadlock or block for + // ever. It returns the cached body instead. + let mut p = a_pool(); + for i in 0..64 { + p.pplns + .record(W_A, pool_core::now_ms().saturating_sub(1_000 + i)); + p.pplns + .record(W_B, pool_core::now_ms().saturating_sub(2_000 + i)); + } + let pool = Arc::new(Mutex::new(p)); + + let t0 = Instant::now(); + let first = stats_body(&pool, t0); + assert!(first.contains("credit"), "the first call really builds it"); + + let served_while_locked = { + // Every miner's request path is now blocked on this guard. + let _held = plock(&pool); + stats_body(&pool, t0 + Duration::from_millis(STATS_CACHE_MS - 1)) + }; + assert_eq!( + served_while_locked, first, + "a request inside the cache window must be served without the pool lock at all" + ); + + // And it does go stale, or the page would freeze at whatever it first + // showed and an operator would be reading history. + let later = stats_body(&pool, t0 + Duration::from_millis(STATS_CACHE_MS + 1)); + assert!(later.contains("credit")); + } + + #[test] + fn a_debt_the_pool_cannot_address_does_not_tax_every_other_miner_for_ever() { + // B6. An owed row whose address the pool cannot pay used to be allocated + // to off the TOP of every cycle, and then dropped by the chunk builder + // when it could not turn the address into an action - so it never + // reached `rows`, `deduct_owed` never cleared it, and it came back next + // cycle. Not a stall: a permanent tax. Every honest miner was short by + // that amount every cycle, for ever, and once the dead amount reached + // the distributable total nobody was paid at all. + const DEAD: &str = "not-an-address"; + let owed = vec![(DEAD.to_string(), 40u64), (W_A.to_string(), 10)]; + + let (plan, left) = take_owed(&owed, 50); + assert_eq!( + plan, + vec![(W_A.to_string(), 10)], + "only the debt the pool can actually pay is allocated to" + ); + assert_eq!( + left, 40, + "the 40 units behind the dead row stay available to everyone else \ + instead of being taken off the top and then not spent" + ); + + // The debt is passed over, NOT forgotten: it is still on the ledger, and + // it is named so an operator hears it from a log rather than from the + // wallet balance quietly climbing. + assert_eq!(unpayable_owed(&owed), vec![(DEAD.to_string(), 40)]); + + // And the whole point: the rest of the money reaches real miners. + let counts = vec![(W_B.to_string(), 1u64)]; + let mut full = plan.clone(); + full.extend(plan_settlement(left, &counts)); + merge_payout_rows(&mut full); + assert_eq!( + full, + vec![(W_A.to_string(), 10), (W_B.to_string(), 40)], + "the 40 units are split over the window instead of vanishing" + ); + + // A dead row that is alone must not swallow the cycle either. + let only_dead = vec![(DEAD.to_string(), 40u64)]; + let (plan, left) = take_owed(&only_dead, 50); + assert!(plan.is_empty()); + assert_eq!( + left, 50, + "nobody is starved by a debt that can never be paid" + ); + } + + #[test] + fn an_owed_row_survives_a_restart_and_outlives_the_share_window() { + // The debt is only as good as the file it lives in: a pool restarted + // between the failed chunk and the next settlement would otherwise forget + // it entirely, and the money would quietly rejoin the pot. let mut owed: Vec<(String, u64)> = Vec::new(); owe_rows(&mut owed, &[(W_A.to_string(), 12), (W_B.to_string(), 3)]); // A second failure for the same miner adds to the debt, never replaces it. @@ -4993,7 +6544,10 @@ mod tests { !msg.contains("when another miner packs it"), "a block this pool mines can carry the payout: {msg}" ); - assert!(msg.contains("4 transaction(s) packed from the node"), "{msg}"); + assert!( + msg.contains("4 transaction(s) packed from the node"), + "{msg}" + ); // What IS observable: how long, and what it is waiting on. assert!(msg.contains("in 5 block(s) / 1500s"), "{msg}"); assert!( @@ -5261,6 +6815,18 @@ mod tests { Arc::new(Account::create_by_secret_key_value([0x11u8; 32]).expect("a valid test key")) } + /// The `n`th distinct REAL payout address. Real, because the settlement + /// filters unpayable keys out, so made-up strings would be removed for a + /// reason that has nothing to do with what a test is asking about. + fn a_wallet_address(n: u64) -> String { + let mut key = [0x22u8; 32]; + key[24..].copy_from_slice(&n.wrapping_add(1).to_be_bytes()); + Account::create_by_secret_key_value(key) + .expect("a valid test key") + .readable() + .to_string() + } + /// A pool with no node, no disk and no listener: enough to exercise the /// accounting the endpoints read. fn a_pool() -> Pool { @@ -5280,6 +6846,8 @@ mod tests { share_factor_achieved: 24, share_cost_bits: 16, share_halt: None, + accounting_halt: None, + node_halt: None, pending_cache: String::new(), workers: HashMap::new(), next_en: 0, @@ -5289,6 +6857,9 @@ mod tests { orphaned: 0, seen: HashSet::new(), submitted: Vec::new(), + // No sleeping in tests. Every test that wants the retry loop itself + // sets its own schedule. + block_submit_delays: &[], immature: Vec::new(), unsaved: 0, state_seq: 0, @@ -5304,6 +6875,8 @@ mod tests { bad_streak: HashMap::new(), tpl_changed_at_ms: 0, rates: HashMap::new(), + rate_untracked: 0, + rate_open_told: None, share_log: ShareLog::default(), } } @@ -5610,7 +7183,7 @@ mod tests { p.pplns .record(W_B, pool_core::now_ms().saturating_sub(60_000)); let pool = Arc::new(Mutex::new(p)); - let r = handle_submission(&pool, W_A, height, [0x11u8; 32], 7); + let r = handle_submission(&pool, W_A, height, [0x11u8; 32], 7, "test-peer"); assert_eq!(r["ok"].as_bool(), Some(false), "{r}"); assert_eq!(r["kind"].as_str(), Some("degraded"), "{r}"); assert_eq!( @@ -5636,9 +7209,7 @@ mod tests { "a halted pool must not even value its wallet: {asked:?}" ); assert!( - !asked - .iter() - .any(|p| p.starts_with("/submit/transaction")), + !asked.iter().any(|p| p.starts_with("/submit/transaction")), "a halted pool must not submit anything: {asked:?}" ); { @@ -5715,9 +7286,7 @@ mod tests { "a halted pool must not value its wallet: {asked:?}" ); assert!( - !asked - .iter() - .any(|p| p.starts_with("/submit/transaction")), + !asked.iter().any(|p| p.starts_with("/submit/transaction")), "a halted pool must not submit anything: {asked:?}" ); } @@ -5797,7 +7366,10 @@ mod tests { let mut p = a_pool(); p.node = node; let rec = a_payout("aa11", 1_000, false, &[(W_A, 25)]); - assert!(!rec.node_holds, "this is the record a timed-out submit leaves"); + assert!( + !rec.node_holds, + "this is the record a timed-out submit leaves" + ); p.payout_records.push(rec); p.settle_pending_txs.push("aa11".to_string()); p.rebuild_inflight(); @@ -5822,6 +7394,76 @@ mod tests { assert_eq!(g.inflight_units, 25); } + #[test] + fn a_payout_the_node_accepted_and_then_lost_is_never_re_signed() { + // A2. The node took these bytes and relayed them before it answered: + // mint/src/api/submit_transaction.rs defaults `async` to false, this pool + // never sends it, and node/src/core/protocol.rs finishes handle_new_tx + // with txpool.insert_by(...) and THEN p2p.broadcast_message(...) before + // returning Ok. + // + // So when the node no longer has the transaction seconds later, it is not + // "never took it". It is "took it, put it on the wire, and lost it" - a + // mempool eviction, or a restart inside this window. The old code deleted + // the record, and with it the only copy of the signed bytes, and put the + // rows back on the owed ledger. The next cycle signed a SECOND + // transaction for the same miners: different timestamp, different hash, + // replay protection by hash alone. If any peer still held the first, both + // are mineable and the operator pays those miners twice. + let (node, _seen) = a_stub_node_answering(vec![ + ( + "/query/balance", + r#"{"ret":0,"list":[{"hacash":"12:248"}]}"#, + ), + // The API takes the bytes: validated, inserted, relayed. + ("/submit/transaction", r#"{"ret":0}"#), + // And moments later the node does not have it. + ( + "/query/transaction", + r#"{"ret":1,"err":"transaction not found"}"#, + ), + ]); + let mut p = a_pool(); + p.node = node; + // Something to split, and something to split it from. + p.pplns + .record(W_A, pool_core::now_ms().saturating_sub(60_000)); + p.matured = Some(Matured { + units: 1_000, + at: 1_500, + }); + let pool = Arc::new(Mutex::new(p)); + + settle_once(&pool); + + let g = plock(&pool); + let rec = g.payout_records.first().expect( + "the signed payout record must survive: it is the only copy of bytes that \ + are already on the network, and losing it is what makes the next cycle \ + sign a second transaction for the same money", + ); + assert!( + !rec.body_hex.is_empty(), + "the signed bytes are what a later cycle rebroadcasts instead of re-signing" + ); + assert!( + g.owed.is_empty(), + "these rows must NOT go back on the owed ledger: they are already payable by a \ + transaction that is out there, and owing them again is how they get paid twice. \ + owed was {:?}", + g.owed + ); + assert!( + g.settle_pending_txs.contains(&rec.hash), + "the hash stays in the pending ledger so no fresh settlement is planned while it \ + is unresolved" + ); + assert!( + g.paid.get(W_A).is_none(), + "and nothing is called paid: the chain has not buried anything" + ); + } + #[test] fn a_settlement_values_and_signs_from_the_one_wallet_the_pool_mines_to() { // What this costs when it is wrong: the settlement thread used to re-read @@ -6036,7 +7678,7 @@ mod tests { } #[test] - fn no_worker_may_submit_faster_than_it_could_have_hashed() { + fn a_tracked_worker_may_not_submit_faster_than_it_could_have_hashed() { // A miner that sits on its shares has a whole interval's worth to insert // at once. Nothing in the submission says when a share was FOUND, so the // only handle the pool has is that finding one costs a known number of @@ -6094,6 +7736,91 @@ mod tests { assert!(p.rate_admits_share(W_B, 1_000)); } + #[test] + fn a_full_rate_table_admits_the_share_and_says_so_once() { + // The limiter fails OPEN when it has nowhere left to track a worker, and + // that stays: refusing an honest miner's share costs it real money, and + // residence weighting is what decides the split anyway. What must not + // happen is that it goes open in SILENCE, because for as long as it lasts + // nothing is holding back a batch of withheld shares dumped at a + // settlement, and the pool is the only thing that can see it. + let mut p = a_pool(); + let now = 5_000_000u64; + + // A pool with room MEASURES the share and spends its budget, so nothing + // is owed and the counter stays at zero. Without this the test also + // passes for an increment on every admission, and the operator line would + // then fire on a healthy pool from its first share: an alarm that is + // always on is the silence this counter exists to end. + let mut healthy = a_pool(); + assert!(healthy.rate_admits_share(W_A, now)); + assert_eq!( + healthy.rate_untracked, 0, + "a share the limiter actually measured is not an untracked one" + ); + assert_eq!( + healthy.rate_open_notice(now), + None, + "a pool whose limiter is running has nothing to report" + ); + + // Every slot held by an id that is still active, so the prune that runs + // before the cap is consulted frees nothing. + for i in 0..RATE_WORKERS { + p.rates.insert( + format!("flood-{i}"), + ShareRate { + shares: 1, + at_ms: now, + }, + ); + } + for i in 0..3 { + assert!( + p.rate_admits_share(W_A, now), + "share {i}: honest work is never refused because a bookkeeping map is full" + ); + } + assert_eq!( + p.rates.len(), + RATE_WORKERS, + "the cap is a memory bound and the map must not grow past it" + ); + assert_eq!( + p.rate_untracked, 3, + "an admission the limiter did not measure has to be counted, or nothing \ + anywhere records that the guard stopped running" + ); + + let line = p + .rate_open_notice(now) + .expect("the operator is told the first time the limiter goes open"); + assert!(line.contains("OPEN"), "{line}"); + assert!(line.contains("3 in this process"), "{line}"); + // Not one line per share: this fires on EVERY submission while it lasts, + // and a line each would bury the block-found notice under it. + assert_eq!(p.rate_open_notice(now), None); + assert!(p.rate_admits_share(W_B, now)); + assert_eq!(p.rate_untracked, 4); + assert_eq!(p.rate_open_notice(now + RATE_OPEN_REPEAT_MS - 1), None); + // Still going five minutes later, so it is said again and carries the + // running total: an operator has to be able to see it has not stopped. + let again = p + .rate_open_notice(now + RATE_OPEN_REPEAT_MS) + .expect("an incident that is still going is repeated, not swallowed"); + assert!(again.contains("4 in this process"), "{again}"); + + // An incident that has ENDED stops repeating. Without the "more than + // zero" guard this says "0 more share(s)" every five minutes forever, + // which is the log flood this notice was shaped to avoid rather than + // cause. + assert_eq!( + p.rate_open_notice(now + RATE_OPEN_REPEAT_MS * 2), + None, + "nothing has been admitted since the last line, so there is nothing to say" + ); + } + #[test] fn a_burst_of_withheld_shares_cannot_take_an_honest_miners_payout() { // End to end through the pool's own accounting, at the point where money @@ -6145,6 +7872,9 @@ mod tests { difficulty: 0x2000_0000, target: [0xff; 32], coinbase_addr: Address::default(), + // A tip stamped NOW, so the default fixture is a healthy chain and + // a test that wants a stalled node has to say so. + prev_timestamp: curtimes(), txs: Arc::new(txs), } } @@ -6355,8 +8085,10 @@ mod tests { // on the share hot path under the pool lock, so it is bounded, and past // the bound the headcount says it is a floor instead of under-reporting // the fleet. - let mut fleet = ShareLog::default(); - fleet.last_ms = Some(t0); + let mut fleet = ShareLog { + last_ms: Some(t0), + ..Default::default() + }; for i in 0..(SHARE_LOG_WORKERS + 50) { assert_eq!(fleet.note(&format!("worker-{i:04}"), 4322, t0), None); } @@ -6379,8 +8111,10 @@ mod tests { // And a clock that steps BACKWARDS (ntp correction, a VM resumed from a // snapshot) must not silence the pool until it catches up. - let mut stepped = ShareLog::default(); - stepped.last_ms = Some(t0); + let mut stepped = ShareLog { + last_ms: Some(t0), + ..Default::default() + }; assert_eq!(stepped.note(W_A, 4322, t0 + 1), None); let back = stepped .note(W_A, 4322, t0 - 3_600_000) @@ -6404,12 +8138,16 @@ mod tests { let pool = Arc::new(Mutex::new(p)); let shares = 16u32; for n in 0..shares { - let r = handle_submission(&pool, W_A, height, [0x22u8; 32], n); + let r = handle_submission(&pool, W_A, height, [0x22u8; 32], n, "test-peer"); assert_eq!(r["kind"].as_str(), Some("share"), "{r}"); } { let g = plock(&pool); - assert_eq!(g.accepted, u64::from(shares), "every share is still credited"); + assert_eq!( + g.accepted, + u64::from(shares), + "every share is still credited" + ); assert!( g.share_log.pending > 0, "the share path is not folding anything into a summary: it is back to \ @@ -6427,7 +8165,7 @@ mod tests { g.network_target = [0xff; 32]; } let before = plock(&pool).share_log.pending; - let r = handle_submission(&pool, W_A, height, [0x22u8; 32], shares); + let r = handle_submission(&pool, W_A, height, [0x22u8; 32], shares, "test-peer"); assert_eq!(r["kind"].as_str(), Some("block"), "{r}"); let g = plock(&pool); assert_eq!( @@ -6505,9 +8243,10 @@ mod tests { // The stamp lives in the 89-byte header every worker hashes, and the pool // pins one template per height. Without it on disk a restart inside a // height invents a new stamp, so the pool serves a DIFFERENT header for the - // SAME height while /query/miner/notice - which signals only a height - // change - stays quiet. Every worker keeps hashing the dead header until - // its scan pass ends and earns nothing for it. + // SAME height while /query/miner/notice stays quiet: a restart re-stamps + // rather than swaps, so the template thread sees no change and the + // parked-job wake-up never fires. Every worker keeps hashing the dead + // header until its scan pass ends and earns nothing for it. let mut path = std::env::temp_dir(); path.push(format!("hbit-pool-stamp-pin-{}", std::process::id())); let path = path.to_string_lossy().to_string(); @@ -6589,16 +8328,591 @@ mod tests { } #[test] - fn a_refused_block_submission_is_recognised_as_a_refusal() { - // A refusal costs a whole block reward, and it used to reach nobody but - // the winning worker's JSON response. - assert!(!block_submit_refused(r#"{"ret":0,"ok":true}"#)); - assert!(block_submit_refused( - r#"{"ret":1,"err":"block parse failed"}"# - )); - assert!(block_submit_refused("http_error: connection refused")); - assert!(block_submit_refused("502 Bad Gateway")); - assert!(block_submit_refused("")); + fn a_block_submission_answer_is_read_as_three_states_not_two() { + use BlockSubmitVerdict::*; + // The node spoke and said yes. + assert_eq!(classify_block_submit(r#"{"ret":0,"ok":true}"#), Queued); + // The node spoke and said no. This one really is a lost reward. + assert_eq!( + classify_block_submit(r#"{"ret":1,"err":"block parse failed"}"#), + Refused + ); + // Nobody said anything. These four used to be indistinguishable from a + // refusal, and each one was announced to the operator as a whole block + // reward gone while the node may never have seen the bytes. + assert_eq!( + classify_block_submit("http_error: connection refused"), + Unresolved + ); + assert_eq!( + classify_block_submit("502 Bad Gateway"), + Unresolved + ); + assert_eq!(classify_block_submit(""), Unresolved); + // Valid JSON from something that is not the node - a proxy, a load + // balancer, a captive portal - carries no verdict at all. + assert_eq!( + classify_block_submit(r#"{"error":"upstream timeout"}"#), + Unresolved + ); + } + + #[test] + fn an_unreadable_answer_is_retried_and_a_spoken_verdict_is_not() { + use std::cell::Cell; + const NO_WAIT: &[Duration] = &[Duration::ZERO, Duration::ZERO, Duration::ZERO]; + + // A node that is simply unreachable is asked again, every time. + let n = Cell::new(0u32); + let (v, _, attempts) = submit_block_with_retries( + || { + n.set(n.get() + 1); + "http_error: connection refused".to_string() + }, + NO_WAIT, + ); + assert_eq!(v, BlockSubmitVerdict::Unresolved); + assert_eq!(attempts, 4, "one attempt plus one per delay"); + assert_eq!(n.get(), 4); + + // A dropped connection followed by an answer: the block lands. This is + // the whole point of the change - the old code lost this block. + let n = Cell::new(0u32); + let (v, _, attempts) = submit_block_with_retries( + || { + n.set(n.get() + 1); + if n.get() < 3 { + String::new() + } else { + r#"{"ret":0}"#.to_string() + } + }, + NO_WAIT, + ); + assert_eq!(v, BlockSubmitVerdict::Queued); + assert_eq!(attempts, 3); + + // A node that answers "no" is believed the first time and not pestered: + // identical bytes earn an identical answer. + let n = Cell::new(0u32); + let (v, _, attempts) = submit_block_with_retries( + || { + n.set(n.get() + 1); + r#"{"ret":1,"err":"nope"}"#.to_string() + }, + NO_WAIT, + ); + assert_eq!(v, BlockSubmitVerdict::Refused); + assert_eq!(attempts, 1); + assert_eq!(n.get(), 1, "a refusal must not be retried"); + } + + #[test] + fn a_refusal_that_follows_silence_is_not_announced_as_a_lost_reward() { + use std::cell::Cell; + const NO_WAIT: &[Duration] = &[Duration::ZERO]; + // First POST is lost on the wire; the node may well have taken the block + // anyway. The second POST is then refused - and the likeliest reason a + // node refuses a block it did not refuse a moment ago is that it already + // holds it. Calling that a lost reward teaches the operator to distrust + // the line that IS a loss, so it stays unresolved. + let n = Cell::new(0u32); + let (v, last, attempts) = submit_block_with_retries( + || { + n.set(n.get() + 1); + if n.get() == 1 { + "http_error: timed out".to_string() + } else { + r#"{"ret":1,"err":"block already exists"}"#.to_string() + } + }, + NO_WAIT, + ); + assert_eq!(v, BlockSubmitVerdict::Unresolved); + assert_eq!(attempts, 2); + assert!( + last.contains("already exists"), + "the operator still sees what the node said" + ); + } + + #[test] + fn the_real_submission_path_retries_a_found_block_and_never_calls_it_lost() { + // Through handle_submission itself, not a re-implementation of it. The + // block path had no test that reached the submit at all, which is how a + // single unretried POST for the most valuable event in the pool survived + // review: every test pinned the idea and none pinned the code. + const TWICE: &[Duration] = &[Duration::ZERO, Duration::ZERO]; + let mut p = a_pool(); + // `node` is empty in a_pool, so every POST fails to build a URL: the + // node is never spoken to and every answer is unreadable. + p.network_target = [0xff; 32]; + p.block_submit_delays = TWICE; + let height = p.tpl.height; + let pool = Arc::new(Mutex::new(p)); + let r = handle_submission(&pool, W_A, height, [0x33u8; 32], 1, "test-peer"); + + assert_eq!(r["kind"].as_str(), Some("block"), "{r}"); + assert_eq!( + r["verdict"].as_str(), + Some("unresolved"), + "an unreadable answer is not a refusal, and reporting it as one told the \ + operator a block was lost that nobody had refused: {r}" + ); + assert_eq!( + r["attempts"].as_u64(), + Some(3), + "the retry schedule has to be reached from the real path, not just from a \ + unit test of the loop: {r}" + ); + // The accounting still happened exactly once, whatever the node said. + let g = plock(&pool); + assert_eq!( + g.submitted.len(), + 1, + "the block is tracked for confirmation" + ); + assert_eq!( + g.immature.len(), + 1, + "its income is held back from settlement" + ); + } + + /// A stub mainnet chain whose tip is at `tip_height`, stamped `tip_unix`. + /// + /// Enough for `template_cycle` to build a real template: the tip, the tip's + /// intro, and the ASERT anchor's intro. Everything else is refused, which + /// leaves the packed transaction set empty and is not what these tests are + /// about. + fn a_stub_chain(tip_height: u64, tip_unix: u64) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind a stub chain"); + let base = format!("http://{}", listener.local_addr().expect("stub address")); + std::thread::spawn(move || { + for s in listener.incoming() { + let Ok(mut s) = s else { continue }; + let mut buf = [0u8; 4096]; + let n = s.read(&mut buf).unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let path = req.split_whitespace().nth(1).unwrap_or("").to_string(); + let body = if path.starts_with("/query/latest") { + format!(r#"{{"ret":0,"height":{tip_height}}}"#) + } else if path.starts_with("/query/block/intro") { + let h: u64 = path + .split("height=") + .nth(1) + .and_then(|s| s.split('&').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + // The anchor keeps its own (old, real) stamp; the tip carries + // whatever this test is asking about. + let ts = if h == tip_height { + tip_unix + } else { + 1_600_000_000 + }; + format!( + r#"{{"ret":0,"hash":"{:064x}","height":{h},"timestamp":{ts},"difficulty":520093695}}"#, + h + ) + } else { + r#"{"ret":1,"errmsg":"stub refuses everything else"}"#.to_string() + }; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = s.write_all(resp.as_bytes()); + let _ = s.flush(); + let _ = s.shutdown(Shutdown::Both); + } + }); + base + } + + #[test] + fn a_node_that_stopped_following_the_chain_halts_the_pool_from_the_real_template_cycle() { + // Through `template_cycle` itself. The check has to sit OUTSIDE the "the + // template changed" branch, and the only way to prove it does is to run + // the cycle against a node whose template never changes - which is + // exactly what a stalled node is. + let now = curtimes(); + let stalled = now - 3 * 3600; // three hours of silence + let node = a_stub_chain(800_000, stalled); + let mut p = a_pool(); + p.node = node.clone(); + let payout = p.payout.clone(); + let pool = Arc::new(Mutex::new(p)); + + template_cycle( + &pool, + &http_client(), + &node, + &payout, + &ChainParams::mainnet(), + false, + ); + + let why = plock(&pool).node_halt.clone().expect( + "a tip three hours old means this node is not following the chain, and every \ + template built on it is worthless work for the rigs pointed here", + ); + assert!(why.contains("800000"), "name the tip: {why}"); + + // And it really binds the money path, not just a field. + let height = { + let mut g = plock(&pool); + // The cycle above rebuilt the share target from the stub chain's real + // difficulty, so accept anything: this test is about the halt, not + // about the arithmetic of a share. + g.share_target = [0xff; 32]; + g.network_target = [0u8; 32]; // an ordinary share, not a block + g.tpl.height + }; + let r = handle_submission(&pool, W_A, height, [0x77u8; 32], 1, "test-peer"); + assert_eq!(r["kind"].as_str(), Some("degraded"), "{r}"); + assert_eq!(plock(&pool).accepted, 0, "nothing was credited"); + } + + #[test] + fn a_node_that_is_keeping_up_is_not_halted_and_a_recovery_clears_it() { + let now = curtimes(); + let node = a_stub_chain(800_000, now - 120); // two minutes: healthy + let mut p = a_pool(); + p.node = node.clone(); + // Start already halted, so this proves the derivation CLEARS as well as + // sets. A halt that only ever latches would leave an operator restarting + // a pool that is already fine. + p.node_halt = Some("stale from a previous cycle".to_string()); + let payout = p.payout.clone(); + let pool = Arc::new(Mutex::new(p)); + + template_cycle( + &pool, + &http_client(), + &node, + &payout, + &ChainParams::mainnet(), + false, + ); + + assert!( + plock(&pool).node_halt.is_none(), + "the tip is two minutes old: this node is keeping up" + ); + } + + /// The 32-byte hash the stub chain reports for height `h`, so a test can + /// make a block that IS ours on that chain. + fn stub_chain_hash(h: u64) -> [u8; 32] { + let mut hx = [0u8; 32]; + hx[24..].copy_from_slice(&h.to_be_bytes()); + hx + } + + #[test] + fn a_one_block_fork_neither_releases_the_hold_back_nor_calls_the_block_orphaned() { + // A4, through the real template_cycle. The chain shows somebody else's + // hash at our height at depth ZERO. The old code released the hold-back + // and tallied the orphan immediately - and a one-block fork usually + // flips back. When it did, the income really was in the wallet, the + // pool no longer knew to hold it, and the next settlement distributed a + // whole subsidy plus fees at no confirmations. + let now = curtimes(); + let tip = 800_000u64; + let node = a_stub_chain(tip, now - 120); + let mut p = a_pool(); + p.node = node.clone(); + let ours = [0xAAu8; 32]; // not what the stub chain shows there + p.submitted.push((tip, ours)); + p.immature.push(Immature { + height: tip, + hash: ours, + units: 100, + fees_counted: false, + claim: Vec::new(), + }); + let payout = p.payout.clone(); + let pool = Arc::new(Mutex::new(p)); + + template_cycle( + &pool, + &http_client(), + &node, + &payout, + &ChainParams::mainnet(), + false, + ); + + let g = plock(&pool); + assert_eq!( + g.immature.len(), + 1, + "a competing hash at depth zero decides nothing: the hold-back stays until the \ + fork is buried as deep as a confirmation would need to be" + ); + assert_eq!(g.orphaned, 0, "and the block is not tallied orphaned"); + assert_eq!( + g.submitted.len(), + 1, + "and the pool keeps watching the height, or a flip-back could never be seen" + ); + } + + #[test] + fn a_competing_hash_buried_sixteen_deep_is_a_real_orphan_and_releases_the_hold_back() { + // The other side of the same gate: once the competing hash is buried + // COINBASE_MATURITY_DEPTH deep, the orphan is as final as a + // confirmation would be. The income never landed, so the hold-back has + // nothing left to hold and keeping it would understate what settlement + // may pay forever. + let now = curtimes(); + let tip = 810_000u64; + let h = tip - COINBASE_MATURITY_DEPTH; + let node = a_stub_chain(tip, now - 120); + let mut p = a_pool(); + p.node = node.clone(); + let ours = [0xBBu8; 32]; + p.submitted.push((h, ours)); + p.immature.push(Immature { + height: h, + hash: ours, + units: 100, + fees_counted: false, + claim: Vec::new(), + }); + let payout = p.payout.clone(); + let pool = Arc::new(Mutex::new(p)); + + template_cycle( + &pool, + &http_client(), + &node, + &payout, + &ChainParams::mainnet(), + false, + ); + + let g = plock(&pool); + assert!( + g.immature.is_empty(), + "a buried orphan releases its hold-back" + ); + assert_eq!(g.orphaned, 1, "and is tallied"); + assert!(g.submitted.is_empty(), "and stops being watched"); + } + + #[test] + fn our_own_block_buried_sixteen_deep_still_confirms_and_releases() { + // The gate must not have broken the ordinary happy path. + let now = curtimes(); + let tip = 820_000u64; + let h = tip - COINBASE_MATURITY_DEPTH; + let node = a_stub_chain(tip, now - 120); + let mut p = a_pool(); + p.node = node.clone(); + let ours = stub_chain_hash(h); // exactly what the stub chain shows + p.submitted.push((h, ours)); + p.immature.push(Immature { + height: h, + hash: ours, + units: 100, + fees_counted: false, + claim: Vec::new(), + }); + let payout = p.payout.clone(); + let pool = Arc::new(Mutex::new(p)); + + template_cycle( + &pool, + &http_client(), + &node, + &payout, + &ChainParams::mainnet(), + false, + ); + + let g = plock(&pool); + assert_eq!(g.blocks, 1, "a buried block of ours confirms"); + assert!( + g.immature.is_empty(), + "and its hold-back is released for settlement" + ); + assert!(g.submitted.is_empty()); + assert_eq!(g.orphaned, 0); + } + + #[test] + fn the_provisional_fork_notice_is_said_once_per_competing_hash() { + let mut st = HashMap::new(); + let a = hex::encode([0xCDu8; 32]); + let b = hex::encode([0xEFu8; 32]); + + // First sighting: announced. + let out = note_contested_heights(&mut st, &[(700, a.clone())]); + assert_eq!(out, vec![(700, a.clone())]); + // Same fork next cycle: silence. The loop runs every two seconds and a + // notice repeated on every cycle is a notice nobody reads. + assert!(note_contested_heights(&mut st, &[(700, a.clone())]).is_empty()); + // The competing hash CHANGES: that is new news. + let out = note_contested_heights(&mut st, &[(700, b.clone())]); + assert_eq!(out, vec![(700, b.clone())]); + // The fork resolves (height no longer sighted), then re-forks with the + // hash it had before: announced again, not remembered as old news. + assert!(note_contested_heights(&mut st, &[]).is_empty()); + let out = note_contested_heights(&mut st, &[(700, b.clone())]); + assert_eq!(out, vec![(700, b)]); + } + + #[test] + fn a_tip_in_the_future_is_clock_skew_and_not_a_reason_to_stop_paying_anyone() { + assert_eq!(tip_too_old(9, 2_000, 1_000, 60), None); + // Exactly at the limit is still fine; one second past it is not. + assert_eq!(tip_too_old(9, 1_000, 1_060, 60), None); + assert!(tip_too_old(9, 1_000, 1_061, 60).is_some()); + } + + #[test] + fn an_accounting_halt_outranks_the_derived_one_and_a_template_change_cannot_clear_it() { + const DISK: &str = "the accounting could not be written to disk"; + let mut p = a_pool(); + assert_eq!(p.halt_reason(), None, "a healthy pool is not halted"); + + p.share_halt = Some("a share costs no work on this chain".to_string()); + assert_eq!(p.halt_reason(), Some("a share costs no work on this chain")); + + p.accounting_halt = Some(DISK.to_string()); + assert_eq!( + p.halt_reason(), + Some(DISK), + "the halt that does NOT heal by itself is the one an operator has to act on, \ + so it is the one reported" + ); + + // This is the entire reason for a second field. `share_halt` is derived: + // every template change rebuilds it from the live difficulty, so a + // durable-write failure written into it would be erased within seconds + // by the chain simply getting better. + p.recompute_share_target(); + assert_eq!( + p.accounting_halt.as_deref(), + Some(DISK), + "a template change must not clear an accounting halt" + ); + assert_eq!(p.halt_reason(), Some(DISK)); + } + + #[test] + fn a_block_whose_accounting_cannot_be_written_halts_the_money_and_still_ships_the_block() { + let mut p = a_pool(); + // A directory that does not exist, so `atomic_write` really fails and + // `flush_state` really returns false. Nothing here is simulated. + let dead = std::env::temp_dir() + .join("hbit-no-such-directory-a41f") + .join("pool.state.json"); + p.state_file = dead.to_string_lossy().into_owned(); + // PERSIST is process-global and every other test in this binary shares + // it. Start well past anything they can have recorded, or this snapshot + // is waved through as already-landed and never attempts the write. + p.state_seq = 9_000_000; + p.network_target = [0xff; 32]; + let height = p.tpl.height; + let pool = Arc::new(Mutex::new(p)); + + let r = handle_submission(&pool, W_A, height, [0x44u8; 32], 3, "test-peer"); + assert_eq!( + r["kind"].as_str(), + Some("block"), + "the block is irreplaceable and the chain does not care what this pool wrote to \ + disk: a bookkeeping failure must not become a certain loss of the reward: {r}" + ); + + let g = plock(&pool); + assert_eq!(g.immature.len(), 1, "the hold-back exists, in memory only"); + let why = g.accounting_halt.clone().expect( + "a block whose hold-back never reached disk has to stop this pool moving money: \ + a restart would read a state file that never learned about the block, and the \ + next settlement would distribute a whole subsidy at 0 confirmations", + ); + assert!( + why.contains(&height.to_string()), + "the operator has to be told WHICH block: {why}" + ); + } + + #[test] + fn an_accounting_halt_refuses_ordinary_shares_but_never_a_block() { + let mut p = a_pool(); + p.network_target = [0u8; 32]; // nothing here reaches the network target + p.accounting_halt = Some("disk full".to_string()); + let height = p.tpl.height; + let pool = Arc::new(Mutex::new(p)); + + let r = handle_submission(&pool, W_A, height, [0x55u8; 32], 1, "test-peer"); + assert_eq!(r["kind"].as_str(), Some("degraded"), "{r}"); + assert_eq!( + r["err"].as_str(), + Some("disk full"), + "a miner is told why, so it can stop burning power: {r}" + ); + assert_eq!(plock(&pool).accepted, 0, "and nothing was credited"); + + // A block is exempt for the same reason it is exempt from every other + // shedding rule in this function: it is a whole reward, it is the + // operator's money rather than a miner's credit, and the halt exists to + // stop money going OUT. + plock(&pool).network_target = [0xff; 32]; + let r = handle_submission(&pool, W_A, height, [0x56u8; 32], 2, "test-peer"); + assert_eq!( + r["kind"].as_str(), + Some("block"), + "a halted pool must still take a block: {r}" + ); + } + + #[test] + fn an_accounting_halt_stops_fresh_settlement_and_still_resolves_what_is_in_flight() { + // The same contract the difficulty halt has, through the same accessor: + // resolving is not paying. A miner whose payout is already on the chain + // must still be credited as PAID, while nothing new is valued or signed. + let (node, seen) = a_stub_node_answering(vec![( + "/query/transaction", + r#"{"ret":0,"confirm":6}"#, // buried at exactly the maturity depth + )]); + let mut p = a_pool(); + p.node = node; + p.accounting_halt = Some("the accounting could not be written to disk".to_string()); + p.payout_records + .push(a_payout("bb22", 1_000, true, &[(W_A, 25)])); + p.settle_pending_txs.push("bb22".to_string()); + p.rebuild_inflight(); + p.pplns + .record(W_B, pool_core::now_ms().saturating_sub(60_000)); + p.matured = Some(Matured { + units: 1_000, + at: 1_500, + }); + let pool = Arc::new(Mutex::new(p)); + + settle_once(&pool); + + let g = plock(&pool); + assert_eq!( + g.paid.get(W_A).map(|r| r.units), + Some(25), + "money already owed and already on the chain still has to reach its miner" + ); + assert!(g.settle_pending_txs.is_empty(), "and stop being tracked"); + let asked = seen.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert!( + !asked.iter().any(|p| p.starts_with("/query/balance")), + "a halted pool must not even value its wallet: {asked:?}" + ); + assert!( + !asked.iter().any(|p| p.starts_with("/submit/transaction")), + "and must not sign or submit anything fresh: {asked:?}" + ); } #[test] @@ -6640,6 +8954,7 @@ mod tests { hash: [hash; 32], units: block_reward_units(height), fees_counted: false, + claim: Vec::new(), } } @@ -6672,7 +8987,10 @@ mod tests { // file, in the exact shape `state_shot` and `PayoutRecord::to_json` used // to emit, loaded by the code that replaces them. let mut path = std::env::temp_dir(); - path.push(format!("hbit-pool-restart-{}.state.json", std::process::id())); + path.push(format!( + "hbit-pool-restart-{}.state.json", + std::process::id() + )); let path = path.to_string_lossy().to_string(); let _ = std::fs::remove_file(&path); let old = json!({ @@ -6704,9 +9022,22 @@ mod tests { }); std::fs::write(&path, old.to_string()).expect("write the old state file"); + // Through the real gate, the same way startup reads it: an old file with + // no `schema` key is schema 1 and must classify Readable. + let j = match classify_state_file(&path) { + StateFile::Readable(j) => *j, + other => panic!( + "an old state file must be readable, not refused: {}", + match other { + StateFile::Fresh => "classified Fresh".to_string(), + StateFile::Unreadable(why) => why, + StateFile::Readable(_) => unreachable!(), + } + ), + }; let mut p = a_pool(); p.state_file = path.clone(); - p.load_state(); + p.load_state(&j); // The window comes back whole, and weighing what the old build weighed // it at: 2 shares to 1, which is the split that build would have paid. @@ -6752,6 +9083,9 @@ mod tests { hash: [0xab; 32], units: 30, fees_counted: false, + // The old file this test is about has no claim, and reading + // that as an empty one is the point: no snapshot, not "nobody". + claim: Vec::new(), }] ); @@ -6771,7 +9105,10 @@ mod tests { ); assert_eq!(back["owed"].as_array().map(|a| a.len()), Some(0)); assert_eq!(back["immature"][0]["fees_counted"].as_bool(), Some(false)); - assert_eq!(back["credit_horizon_ms"].as_u64(), Some(p.pplns.horizon_ms())); + assert_eq!( + back["credit_horizon_ms"].as_u64(), + Some(p.pplns.horizon_ms()) + ); assert_eq!(back["paid"]["rows"][0]["units"].as_u64(), Some(41)); let _ = std::fs::remove_file(&path); } @@ -6833,10 +9170,15 @@ mod tests { assert!(u.contains(arg), "usage never explains {arg}:\n{u}"); } // A command that really works, with the required arguments in place. + // The share size is read from the constant rather than typed here, so + // the example cannot drift from the value the project recommends - which + // is exactly what happened when the constant said 24 and two of the three + // shipped deployment files said 20. assert!( - u.contains( - "hbit-pool-server http://127.0.0.1:8080 pool-wallet.key 0.0.0.0:9777 24 mainnet" - ), + u.contains(&format!( + "hbit-pool-server http://127.0.0.1:8080 pool-wallet.key 0.0.0.0:9777 \ + {DEFAULT_SHARE_BITS} mainnet" + )), "usage has no working example:\n{u}" ); // The bounds and defaults are read from the constants that enforce them, diff --git a/hbit-pool/src/settle.rs b/hbit-pool/src/settle.rs index afdb9c13..cf79fe77 100644 --- a/hbit-pool/src/settle.rs +++ b/hbit-pool/src/settle.rs @@ -47,7 +47,7 @@ fn main() { let client = http_client(); - // Deterministic accounts we control (public keys — testnet demo only). + // Deterministic accounts we control (public keys - testnet demo only). let sender = Account::create_by_secret_key_value([1u8; 32]).expect("sender account"); let recipients: Vec<(Account, &str)> = vec![ ( @@ -110,7 +110,7 @@ fn main() { ); } - // (a) submit to the mempool — the pool's normal action. + // (a) submit to the mempool - the pool's normal action. let resp = post_hex( &client, &format!("{base}/submit/transaction?hexbody=true"), @@ -160,6 +160,6 @@ fn main() { recipients.len() ); } else { - println!("\nNot all recipients funded yet — check the responses above."); + println!("\nNot all recipients funded yet - check the responses above."); } } diff --git a/hbit-pool/tests/block_fee_holdback_node_fixtures.rs b/hbit-pool/tests/block_fee_holdback_node_fixtures.rs index 06ca07de..5af657de 100644 --- a/hbit-pool/tests/block_fee_holdback_node_fixtures.rs +++ b/hbit-pool/tests/block_fee_holdback_node_fixtures.rs @@ -179,9 +179,19 @@ fn write_reply(stream: &mut TcpStream, r: &Reply) { let _ = stream.flush(); } -fn ask(intro: Reply, txs: Vec<(&'static str, Reply)>, height: u64, our_hash: &str) -> BlockFees { +/// `tip` is what the node's own tip is supposed to be while it answers, because +/// what a refusal MEANS depends on which side of it the height stands: above the +/// tip a missing block is ordinary waiting, at or below it the node is failing +/// to produce a block it must hold. +fn ask( + intro: Reply, + txs: Vec<(&'static str, Reply)>, + height: u64, + our_hash: &str, + tip: u64, +) -> BlockFees { let node = stub_node(intro, txs.into_iter().collect()); - block_fees(&http_client(), &node.base, height, our_hash) + block_fees(&http_client(), &node.base, height, our_hash, tip) } /// The figure the settlement path actually holds back for one immature block: @@ -202,7 +212,7 @@ fn holdback_units(height: u64, fees: &BlockFees) -> Option { #[test] fn a_real_block_with_no_transactions_holds_back_the_subsidy_and_no_more() { - let got = ask(Reply::ok(BLOCK_307_0TX), vec![], 307, HASH_307); + let got = ask(Reply::ok(BLOCK_307_0TX), vec![], 307, HASH_307, 310); assert_eq!(got, BlockFees::Counted(0), "body: {BLOCK_307_0TX}"); // 1 HAC of subsidy is 10 payout units of 0.1 HAC, and the fixture's own @@ -231,6 +241,7 @@ fn three_real_transaction_fees_are_summed_before_they_are_rounded_up_once() { ], 309, HASH_309, + 310, ); // 0.01 + 0.003 + 0.0007 HAC = 0.0137 HAC = 13_700_000 fine steps, which is @@ -253,7 +264,7 @@ fn three_real_transaction_fees_are_summed_before_they_are_rounded_up_once() { for h in [TXH_309_A, TXH_309_B, TXH_309_C] { assert!(BLOCK_309_3TX.contains(h), "missing {h}"); } - assert_eq!(BLOCK_309_3TX.matches("\",\"").count() > 0, true); + assert!(BLOCK_309_3TX.matches("\",\"").count() > 0); } #[test] @@ -266,6 +277,7 @@ fn fees_one_fine_step_past_a_unit_boundary_hold_back_the_next_whole_unit() { ], 308, HASH_308, + 310, ); // 0.1 HAC is exactly one payout unit; the second fee is a single fine step @@ -302,6 +314,7 @@ fn a_fee_rendered_in_an_unusual_unit_stops_settlement_instead_of_reading_as_zero ], 309, HASH_309, + 310, ); assert!( @@ -324,14 +337,14 @@ fn a_malformed_block_body_stops_settlement_instead_of_reading_as_zero() { // The real body of a 404 from this node: empty. `get_json` cannot parse it, // so it arrives as a bare JSON string rather than an object. assert_eq!(EMPTY_404_BODY, ""); - let got = ask(Reply::not_found(EMPTY_404_BODY), vec![], 309, HASH_309); + let got = ask(Reply::not_found(EMPTY_404_BODY), vec![], 309, HASH_309, 310); assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); assert_eq!(holdback_units(309, &got), None); // A real intro body cut short - what a dropped connection or a proxy buffer // limit produces. Valid JSON never resumes, so it must not be believed. let truncated = &BLOCK_309_3TX[..BLOCK_309_3TX.len() / 2]; - let got = ask(Reply::ok(truncated), vec![], 309, HASH_309); + let got = ask(Reply::ok(truncated), vec![], 309, HASH_309, 310); assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); assert_eq!(holdback_units(309, &got), None); @@ -345,7 +358,7 @@ fn a_malformed_block_body_stops_settlement_instead_of_reading_as_zero() { ); assert!(!no_list.contains("tx_hash_list"), "fixture text drifted"); assert!(no_list.contains(HASH_309) && no_list.contains(r#""ret":0"#)); - let got = ask(Reply::ok(&no_list), vec![], 309, HASH_309); + let got = ask(Reply::ok(&no_list), vec![], 309, HASH_309, 310); assert!( matches!(got, BlockFees::Unknown(_)), "a missing tx_hash_list must never read as an empty one, got {got:?}" @@ -354,7 +367,7 @@ fn a_malformed_block_body_stops_settlement_instead_of_reading_as_zero() { // A transaction body cut short mid-JSON: the stub answers this way for any // hash it does not know. - let got = ask(Reply::ok(BLOCK_309_3TX), vec![], 309, HASH_309); + let got = ask(Reply::ok(BLOCK_309_3TX), vec![], 309, HASH_309, 310); assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); assert_eq!(holdback_units(309, &got), None); } @@ -369,14 +382,14 @@ fn the_nodes_own_error_objects_are_read_for_what_they_actually_say() { // that height, so it credited us nothing and there is nothing to hold back. assert!(BLOCK_MISSING_ERR.contains(r#""ret":1"#)); assert!(BLOCK_MISSING_ERR.contains("cannot find block")); - let got = ask(Reply::ok(BLOCK_MISSING_ERR), vec![], 999_999, HASH_309); + let got = ask(Reply::ok(BLOCK_MISSING_ERR), vec![], 999_999, HASH_309, 310); assert_eq!(got, BlockFees::NotOnChain); assert_eq!(holdback_units(999_999, &got), Some(0)); // The chain holds a block at our height, but it is not ours - another block // won it. Also nothing credited. (Real 307 body, asked about as if it were // our block.) - let got = ask(Reply::ok(BLOCK_307_0TX), vec![], 307, HASH_309); + let got = ask(Reply::ok(BLOCK_307_0TX), vec![], 307, HASH_309, 310); assert_eq!(got, BlockFees::NotOnChain); // But an error on a transaction the node ITSELF just listed in our block is @@ -392,6 +405,7 @@ fn the_nodes_own_error_objects_are_read_for_what_they_actually_say() { ], 309, HASH_309, + 310, ); assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); assert_eq!(holdback_units(309, &got), None); @@ -407,10 +421,38 @@ fn the_nodes_own_error_objects_are_read_for_what_they_actually_say() { ], 309, HASH_309, + 310, ); assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); } +// --------------------------------------------------------------------------- +// The same refusal on the wrong side of the tip. +// --------------------------------------------------------------------------- + +#[test] +fn a_missing_block_under_the_nodes_own_tip_stops_settlement_instead_of_reading_as_zero() { + // Identical bytes to the definitive case above - the node's real + // "cannot find block" error object - but this time the height is one the + // node's own tip covers. A node that holds a chain to 310 and cannot + // produce block 309 is not answering; it is failing. Our block may be + // canonical right there, with its fee income already sitting in the pool + // wallet, and pricing that refusal as "no fees" is what used to hand the + // fee income to miners at zero confirmations. + assert!(BLOCK_MISSING_ERR.contains(r#""ret":1"#)); + let got = ask(Reply::ok(BLOCK_MISSING_ERR), vec![], 309, HASH_309, 310); + assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); + assert_eq!( + holdback_units(309, &got), + None, + "None means: settle nothing this cycle" + ); + + // At the tip exactly: the same. The tip IS a height the node must hold. + let got = ask(Reply::ok(BLOCK_MISSING_ERR), vec![], 310, HASH_309, 310); + assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); +} + // --------------------------------------------------------------------------- // No node at all. // --------------------------------------------------------------------------- @@ -423,7 +465,7 @@ fn an_unreachable_node_stops_settlement_instead_of_reading_as_zero() { l.local_addr().expect("addr").port() }; let base = format!("http://127.0.0.1:{port}"); - let got = block_fees(&http_client(), &base, 309, HASH_309); + let got = block_fees(&http_client(), &base, 309, HASH_309, 310); assert!(matches!(got, BlockFees::Unknown(_)), "got {got:?}"); assert_eq!(holdback_units(309, &got), None); } @@ -444,6 +486,7 @@ fn the_fee_half_of_the_holdback_is_what_keeps_it_from_being_paid_out() { ], 308, HASH_308, + 310, ); assert_eq!(fees, BlockFees::Counted(2)); @@ -459,7 +502,11 @@ fn the_fee_half_of_the_holdback_is_what_keeps_it_from_being_paid_out() { // Subsidy plus fees: nothing is payable until the block matures. assert_eq!( - distributable_units(balance, holdback_units(308, &fees).expect("counted"), reserve), + distributable_units( + balance, + holdback_units(308, &fees).expect("counted"), + reserve + ), None ); } diff --git a/hbit-pool/tests/mainnet_chain_identity.rs b/hbit-pool/tests/mainnet_chain_identity.rs new file mode 100644 index 00000000..cc5a080a --- /dev/null +++ b/hbit-pool/tests/mainnet_chain_identity.rs @@ -0,0 +1,332 @@ +//! Does this pool know which chain its node is on? +//! +//! Every other startup check asks the node about its own tip and verifies the +//! answer is self-consistent: the tip's difficulty really does follow from the +//! block before it. A node on a different chain passes all of them effortlessly, +//! because it is perfectly consistent with itself. +//! +//! The failure that costs money is not exotic. A node stalled part-way through a +//! sync, or pointed at a private chain, answers every question happily. The pool +//! then mines a chain nobody else is on, watches its own blocks get buried +//! sixteen deep THERE, releases the coinbase hold-back on that evidence and +//! signs real payouts out of a real wallet against income the real chain never +//! credited. Miners burn real power for shares that can never mature. +//! +//! These tests drive `verify_chain_params` against a stub node over real HTTP. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; + +use hbit_pool::difficulty::ChainParams; +use hbit_pool::{http_client, mainnet_genesis_hex, verify_chain_params}; + +/// The mainnet genesis hash, written out once. +/// +/// The pool derives this from `mint::genesis` and never from a literal, so this +/// literal exists only to catch the constant itself moving. The same value is +/// proved from the genesis header bytes in x16rs-cuda/tests/genesis_vector.rs, +/// where it is the output of hashing the block rather than an assertion about it. +const MAINNET_GENESIS: &str = "000000077790ba2fcdeaef4a4299d9b667135bac577ce204dee8388f1b97f7e6"; + +#[test] +fn the_pool_and_the_node_agree_on_what_mainnet_block_zero_is() { + assert_eq!( + mainnet_genesis_hex(), + MAINNET_GENESIS, + "the canonical genesis hash moved. Either mainnet changed, which it did not, or \ + something edited the constant every payout in this pool is anchored to" + ); +} + +struct StubNode { + base: String, + _thread: std::thread::JoinHandle<()>, +} + +/// A node that answers `/query/latest` with `tip`, and `/query/block/intro` with +/// whatever `intro_for` returns for the requested height. +fn stub_node( + tip: u64, + intro_for: impl Fn(u64) -> Option + Send + Sync + 'static, +) -> StubNode { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub node"); + let port = listener.local_addr().expect("stub addr").port(); + let intro_for = Arc::new(intro_for); + let thread = std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let Some(target) = request_target(&mut stream) else { + continue; + }; + let body = if target.contains("/query/latest") { + Some(format!( + r#"{{"ret":0,"list":[{{"height":{tip},"diamond":0}}]}}"# + )) + } else if target.contains("/query/block/intro") { + let h = target + .split("height=") + .nth(1) + .and_then(|s| s.split('&').next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(u64::MAX); + intro_for(h) + } else { + None + }; + match body { + Some(b) => write_json(&mut stream, &b), + None => write_json(&mut stream, r#"{"ret":1,"err":"not found"}"#), + } + } + }); + StubNode { + base: format!("http://127.0.0.1:{port}"), + _thread: thread, + } +} + +fn request_target(stream: &mut TcpStream) -> Option { + let mut reader = BufReader::new(stream.try_clone().ok()?); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + loop { + let mut h = String::new(); + if reader.read_line(&mut h).ok()? == 0 || h == "\r\n" || h == "\n" { + break; + } + } + line.split_whitespace().nth(1).map(|s| s.to_string()) +} + +fn write_json(stream: &mut TcpStream, body: &str) { + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); +} + +/// A BLOCK 1 answer whose `prevhash` is `genesis`. +/// +/// Block 1, not block 0, because that is what a real node serves. Verified +/// against a live mainnet node at height 771593: `?height=0` answers +/// "cannot find block" and so does a lookup by the genesis hash, because the +/// handler defaults `height` to 0 and cannot tell zero from "not given". Block +/// 1's prevhash IS the genesis hash by construction. +fn genesis_reply(genesis: &str) -> String { + format!( + r#"{{"ret":0,"hash":"001e231cb03f9938d54f04407797b8188f0375eb10f0bcb426dccae87dcadb56","prevhash":"{genesis}","height":1,"timestamp":1549250864,"difficulty":4294967294}}"# + ) +} + +#[test] +fn a_node_on_another_chain_is_refused_by_its_genesis() { + // A private chain, a testnet, a node someone else's pool is running: all of + // them answer every other check correctly and only differ here. + let node = stub_node(800_000, |h| { + (h == 1) + .then(|| genesis_reply("dead00000000000000000000000000000000000000000000000000000beef")) + }); + let err = verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) + .expect_err("a foreign genesis must stop this pool before it credits a single share"); + assert!( + err.contains("NOT on the chain"), + "the operator has to be told what is wrong, not just that something is: {err}" + ); + assert!( + err.contains(MAINNET_GENESIS), + "and has to be shown what was expected: {err}" + ); +} + +#[test] +fn a_node_with_no_chain_at_all_cannot_identify_itself_and_is_refused() { + // A tip of 0 used to return Ok unconditionally, on the reasoning that an + // empty chain has stored nothing to compare to. A node with no blocks + // genuinely cannot be identified - this one does not even serve its own + // genesis - so the answer is to refuse, not to wave it through. A mainnet + // node has 700000 blocks or more. + let node = stub_node(0, |_| None); + let err = verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) + .expect_err("a node with no chain cannot prove which chain it is"); + assert!(err.contains("no blocks at all"), "{err}"); +} + +#[test] +fn a_chain_that_begins_somewhere_else_is_refused() { + let node = stub_node(5, |h| { + (h == 1).then(|| { + genesis_reply("00000000000000000000000000000000000000000000000000000000000000ff") + }) + }); + let err = verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) + .expect_err("a chain beginning elsewhere is not mainnet"); + assert!(err.contains("NOT on the chain"), "{err}"); +} + +#[test] +fn the_genesis_hash_is_matched_without_caring_about_hex_case() { + let node = stub_node(800_000, |h| match h { + 1 => Some(genesis_reply(&MAINNET_GENESIS.to_uppercase())), + 800_000 => Some(block_reply(800_000, now_unix() - 60)), + other => Some(block_reply(other, 1_600_000_000)), + }); + match verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) { + Ok(()) => {} + Err(e) => assert!( + !e.contains("NOT on the chain"), + "hex case is a rendering choice, not a different chain: {e}" + ), + } +} + +#[test] +fn a_node_that_cannot_produce_block_one_is_refused_rather_than_assumed_good() { + // Unknown is not permission. A node that will not answer for block 1 has + // not identified itself, and this pool is about to hold other people's money + // on the strength of that identification. + let node = stub_node(800_000, |_| None); + let err = verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) + .expect_err("no answer must not read as the right answer"); + assert!( + err.contains("could not read block 1"), + "the reason has to name what was missing: {err}" + ); +} + +/// An intro reply for a block at `h`, stamped `ts`. +fn block_reply(h: u64, ts: u64) -> String { + format!(r#"{{"ret":0,"hash":"{h:064x}","height":{h},"timestamp":{ts},"difficulty":520093695}}"#) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_secs() +} + +#[test] +fn a_node_stalled_on_the_right_chain_is_refused_at_startup() { + // The identity check proves the node knows what mainnet is. It says nothing + // about whether the node is anywhere near the end of it, and a node stalled + // part-way through a sync answers everything so far with total confidence. + // This is the documented failure of this deployment: history sync finishes + // short of the tip and then ignores live blocks until it is restarted. + let stale = now_unix() - 4 * 3600; + let node = stub_node(800_000, move |h| match h { + 1 => Some(genesis_reply(MAINNET_GENESIS)), + 800_000 => Some(block_reply(800_000, stale)), + other => Some(block_reply(other, 1_600_000_000)), + }); + let err = verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) + .expect_err("a pool must not start against a node that stopped following the chain"); + assert!( + err.contains("silence"), + "the operator has to be told the chain has gone quiet, not just that something \ + failed: {err}" + ); + assert!( + err.contains("start the pool again"), + "and what to do about it: {err}" + ); +} + +#[test] +fn a_node_at_a_live_tip_gets_past_the_staleness_gate() { + // The other side of the door. This node is refused later, on the difficulty + // rule, because the stub is not a real chain - but it must not be refused + // for being stale, or the gate would be refusing everyone. + let node = stub_node(800_000, move |h| match h { + 1 => Some(genesis_reply(MAINNET_GENESIS)), + 800_000 => Some(block_reply(800_000, now_unix() - 60)), + other => Some(block_reply(other, 1_600_000_000)), + }); + match verify_chain_params(&http_client(), &node.base, &ChainParams::mainnet()) { + Ok(()) => {} + Err(e) => assert!( + !e.contains("silence"), + "a tip one minute old is a live chain: {e}" + ), + } +} + +#[test] +fn a_testnet_is_not_checked_against_a_genesis_it_cannot_have() { + // Documented trade-off. A testnet's genesis depends on whoever started that + // chain, so there is nothing to verify against and pretending otherwise + // would refuse every legitimate testnet. The identity guarantee in this file + // is a MAINNET guarantee, and the pool's own production posture is mainnet. + let node = stub_node(0, |h| { + (h == 0).then(|| { + genesis_reply("00000000000000000000000000000000000000000000000000000000000000ff") + }) + }); + verify_chain_params(&http_client(), &node.base, &ChainParams::testnet(10, 10)) + .expect("a testnet genesis is whatever its operator made it"); +} + +/// The operator runbook that ships in the release archive, checked as text. +/// +/// A doc claim about a refusal is a safety claim. An operator who believes the +/// pool refuses to start on a syncing node stops watching the node's own sync, +/// and this deployment's known failure is a history sync that finishes short of +/// the tip and then ignores live blocks. The runbook said exactly that for as +/// long as nothing was checking it, because a sentence in a markdown file is +/// the one part of this pool the compiler never reads. +#[test] +fn the_runbook_does_not_promise_a_syncing_refusal_the_pool_cannot_make() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .join("docs/POOL-OPERATOR.md"); + let raw = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + // Flattened, so re-wrapping a paragraph can neither hide a claim from this + // test nor fake one into it. + let doc = raw.split_whitespace().collect::>().join(" "); + + // There is no such check and there cannot be one here: the node's + // /query/latest answers with a height and a diamond number, and everything + // this file drives - the genesis hash and the tip timestamp - is evidence + // about the chain, never about the node's own sync state. + assert!( + !doc.contains("A node that is still syncing"), + "the runbook promises a refusal on a syncing node. No such check exists, \ + and the node's API cannot report sync state at all" + ); + + // What the pool really refuses and halts on, each named where an operator + // will look for it. + for claim in [ + "block 1", + "3600 seconds", + "7200 seconds", + "/query/latest", + "cannot detect a syncing node", + "hbit-v2/MAINNET-SAFETY.md", + ] { + assert!( + doc.contains(claim), + "the runbook has to say what the pool really does about the node, and \ + `{claim}` is missing from it" + ); + } + + // The link above has to resolve in the archive as well as in the repository, + // and the archive is flat: POOL-OPERATOR.md sits at its root beside the + // copied directory. So the copy has to keep this name. + let workflow = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .join(".github/workflows/release-pool.yml"); + let packaging = std::fs::read_to_string(&workflow).expect("read release-pool.yml"); + assert!( + packaging.contains("cp -r docs/hbit-v2 \"$pooldir/hbit-v2\""), + "the runbook links to hbit-v2/MAINNET-SAFETY.md, so the release archive has to \ + copy that directory under exactly that name or the link is dead for every \ + operator who reads the shipped copy" + ); +} diff --git a/hbit-pool/tests/refuses_empty_accounting_beside_a_wallet.rs b/hbit-pool/tests/refuses_empty_accounting_beside_a_wallet.rs new file mode 100644 index 00000000..2e7f2e9f --- /dev/null +++ b/hbit-pool/tests/refuses_empty_accounting_beside_a_wallet.rs @@ -0,0 +1,203 @@ +//! Does the pool refuse to start with empty accounting beside a funded wallet? +//! +//! This is the failure the whole B1 fix exists to stop. Before it, a state file +//! the pool could not read - a permission change, a half-written file, a +//! restored backup with the wrong owner - left the server running with zero +//! owed, zero paid, zero in flight. The next settlement then distributed the +//! whole wallet balance to whoever was in the share window: every debt +//! forgotten, every payout already on the wire re-signed, every maturing block +//! paid at zero confirmations. +//! +//! It drives the REAL hbit-pool-server binary against a stub mainnet node, with +//! a real wallet file and a corrupt ledger beside it, and requires the process +//! to refuse rather than serve. The classify decision is unit-tested in lib.rs; +//! this proves the decision is actually WIRED into startup, ahead of anything +//! that moves money. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::process::Command; + +/// The mainnet genesis hash, from the node's own constant via the crate. +fn genesis_hex() -> String { + hbit_pool::mainnet_genesis_hex() +} + +struct Stub { + base: String, + _thread: std::thread::JoinHandle<()>, +} + +/// The least a node must answer for `verify_chain_params` to pass on an EMPTY +/// chain: its tip is height 0, and block 0 is the mainnet genesis. That is +/// enough to reach the ledger gate, which is all this test is about - it is not +/// a mining test. +fn stub_empty_mainnet() -> Stub { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); + let base = format!("http://{}", listener.local_addr().expect("addr")); + let g = genesis_hex(); + let thread = std::thread::spawn(move || { + for s in listener.incoming() { + let Ok(mut s) = s else { break }; + let Some(target) = request_target(&mut s) else { + continue; + }; + let body = if target.starts_with("/query/latest") { + r#"{"ret":0,"height":0,"diamond":0}"#.to_string() + } else if target.starts_with("/query/block/intro") { + format!( + r#"{{"ret":0,"hash":"{g}","height":0,"timestamp":1549250700,"difficulty":1}}"# + ) + } else { + r#"{"ret":1,"errmsg":"stub refuses everything else"}"#.to_string() + }; + let _ = write!( + s, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + content-length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = s.flush(); + } + }); + Stub { + base, + _thread: thread, + } +} + +fn request_target(s: &mut TcpStream) -> Option { + let mut r = BufReader::new(s.try_clone().ok()?); + let mut line = String::new(); + r.read_line(&mut line).ok()?; + loop { + let mut h = String::new(); + if r.read_line(&mut h).ok()? == 0 || h == "\r\n" || h == "\n" { + break; + } + } + line.split_whitespace().nth(1).map(|s| s.to_string()) +} + +fn server_binary() -> std::path::PathBuf { + let mut p = std::env::current_exe().expect("test exe"); + p.pop(); + if p.ends_with("deps") { + p.pop(); + } + p.join(format!("hbit-pool-server{}", std::env::consts::EXE_SUFFIX)) +} + +#[test] +fn a_corrupt_ledger_beside_a_wallet_refuses_to_start_and_does_not_touch_the_file() { + let bin = server_binary(); + assert!( + bin.exists(), + "{} was not built; this test proves nothing without it", + bin.display() + ); + + let dir = std::env::temp_dir().join(format!("hbit-b1-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + + // A real wallet file, made the way the server would make it, so the refusal + // is unambiguously about the LEDGER and not a missing wallet. + let wallet = dir.join("pool-wallet.key"); + let _acc = hbit_pool::load_or_create_wallet(&wallet.to_string_lossy()); + + // A ledger the pool cannot read: a half-written JSON object. This is exactly + // what a crash mid-write leaves, and it used to be swallowed. + let state = dir.join("pool-wallet.key.state.json"); + let corrupt = r#"{"schema":1,"owed":[["addr",100],"#; + std::fs::write(&state, corrupt).expect("write corrupt ledger"); + + let node = stub_empty_mainnet(); + + let out = Command::new(&bin) + .arg(&node.base) // + .arg(&wallet) // + .arg("127.0.0.1:0") // - an ephemeral port; we never serve + .arg("24") // + .arg("mainnet") // + .output() + .expect("run hbit-pool-server"); + + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + assert!( + !out.status.success(), + "the server must EXIT rather than serve on an unreadable ledger. Output:\n{text}" + ); + assert!( + text.contains("REFUSING to start"), + "the operator has to be told plainly why. Output:\n{text}" + ); + assert!( + text.contains("empty accounting") || text.contains("pay the current"), + "and told the money stake, not just that a file was bad. Output:\n{text}" + ); + + // The file must be untouched: it is the only copy of the accounting, and a + // refusal that mangled it would be worse than the bug it replaced. + let after = std::fs::read_to_string(&state).expect("the ledger file must still be there"); + assert_eq!( + after, corrupt, + "the refusal must not alter the accounting file" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_wallet_with_a_readable_empty_ledger_is_not_refused_for_that() { + // The other side of the gate: a genuinely fresh, well-formed ledger must NOT + // trip the ledger refusal. This minimal stub is an EMPTY chain, so the + // server will exit shortly afterward for an unrelated reason (it cannot + // build a template to mine on genesis alone). That is fine: the claim here + // is narrow and precise - the ledger gate did not fire - so it checks the + // output text rather than the exit code, which the corrupt-ledger test + // above already owns. + let bin = server_binary(); + assert!(bin.exists(), "{} not built", bin.display()); + + let dir = std::env::temp_dir().join(format!("hbit-b1-ok-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let wallet = dir.join("pool-wallet.key"); + let _acc = hbit_pool::load_or_create_wallet(&wallet.to_string_lossy()); + // A well-formed, empty-but-valid ledger. + std::fs::write( + dir.join("pool-wallet.key.state.json"), + r#"{"schema":1,"window":4096,"order":[],"banked":[],"owed":[],"immature":[],"payouts_inflight":[],"settle_pending_txs":[]}"#, + ) + .expect("write ledger"); + + let node = stub_empty_mainnet(); + let out = Command::new(&bin) + .arg(&node.base) + .arg(&wallet) + .arg("127.0.0.1:0") + .arg("24") + .arg("mainnet") + .output() + .expect("run hbit-pool-server"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + assert!( + !text.contains("empty accounting") && !text.contains("pay the current share window"), + "a valid empty ledger beside a wallet must not trip the ledger refusal; the pool has \ + to be able to start for the first time. Output:\n{text}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/hbit-pool/tests/shipped_node_configs_are_servable.rs b/hbit-pool/tests/shipped_node_configs_are_servable.rs new file mode 100644 index 00000000..02485f32 --- /dev/null +++ b/hbit-pool/tests/shipped_node_configs_are_servable.rs @@ -0,0 +1,129 @@ +//! Will the node actually serve the API on the configs this repository ships? +//! +//! The node has a rule of its own, in `server/src/server/server.rs`: +//! +//! ```text +//! if !addr.ip().is_loopback() && ser.cnf.api_token.is_empty() { +//! println!("[Error] api server bind ... is not loopback but api_token is empty"); +//! return; +//! } +//! ``` +//! +//! It does not exit. It prints one line and returns from the listen task, so the +//! process keeps running and keeps syncing the chain, looking healthy in every +//! way except the one that matters: nothing is listening on the API port. +//! +//! `deploy/node/hacash.config.ini` shipped `bind = 0.0.0.0` with no `api_token` +//! at all, and its own header comment called that "correct INSIDE a container". +//! It is not correct anywhere. The compose healthcheck curls `/query/latest` and +//! would never have passed, the pool waits on `service_healthy` and would never +//! have started, and an operator would have seen a node happily syncing and a +//! pool that never came up. +//! +//! This test applies the node's own rule to every node config in the tree, so +//! the next one cannot ship broken either. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// The `[section] key = value` pairs of an ini file, lowercased keys, comments +/// (`;` and `#`) and blank lines dropped. Deliberately a small independent +/// reader rather than the node's own: a test that parses with the code under +/// test cannot catch a config the code would reject. +fn ini_pairs(text: &str) -> HashMap<(String, String), String> { + let mut out = HashMap::new(); + let mut section = String::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with(';') || line.starts_with('#') { + continue; + } + if let Some(rest) = line.strip_prefix('[') { + section = rest + .split(']') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + continue; + } + if let Some((k, v)) = line.split_once('=') { + out.insert( + (section.clone(), k.trim().to_ascii_lowercase()), + v.trim().to_string(), + ); + } + } + out +} + +/// Every `hacash.config.ini` this repository ships to an operator. +fn shipped_node_configs() -> Vec { + // The crate root is hbit-pool/, so the repository is one level up. + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .to_path_buf(); + ["deploy/node/hacash.config.ini"] + .iter() + .map(|p| root.join(p)) + .filter(|p| p.exists()) + .collect() +} + +#[test] +fn every_shipped_node_config_is_one_the_node_will_actually_serve() { + let configs = shipped_node_configs(); + assert!( + !configs.is_empty(), + "no node config was found to check; this test must not pass by finding nothing" + ); + + for path in configs { + let text = std::fs::read_to_string(&path).expect("read node config"); + let ini = ini_pairs(&text); + + let enabled = ini + .get(&("server".into(), "enable".into())) + .map(|v| v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if !enabled { + continue; // no API means nothing to serve and nothing to check + } + + let bind = ini + .get(&("server".into(), "bind".into())) + .cloned() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let token = ini + .get(&("server".into(), "api_token".into())) + .cloned() + .unwrap_or_default(); + + // The node's own test, verbatim: a non-loopback bind with no token is + // refused. "0.0.0.0" is NOT loopback - that is the whole trap, because + // it reads like "everything, including localhost". + let loopback = bind == "127.0.0.1" || bind == "::1" || bind == "localhost"; + assert!( + loopback || !token.trim().is_empty(), + "{}: [server] bind = {bind} is not loopback and api_token is empty. The node will \ + print one line and never listen, while the process keeps running and syncing. The \ + pool would report the node as down for ever.", + path.display() + ); + } +} + +#[test] +fn a_client_built_with_a_token_is_not_the_same_as_one_without() { + // The pool sends the token on the CLIENT, so no call site can forget it. + // This pins that an empty token really does mean "no header" - every + // existing loopback deployment depends on that being unchanged - and that a + // token is accepted rather than silently dropped. + let _plain = hbit_pool::http_client_with_token(""); + let _authed = hbit_pool::http_client_with_token("a-real-token"); + // A token containing bytes no header can carry must not panic the pool; it + // warns and sends nothing, which the node then refuses, which the operator + // sees. Panicking here would take down a running pool instead. + let _bad = hbit_pool::http_client_with_token("bad\ntoken"); +} diff --git a/hbit-pool/tests/the_payout_tool_settles_off_the_ledger_only.rs b/hbit-pool/tests/the_payout_tool_settles_off_the_ledger_only.rs new file mode 100644 index 00000000..3c99770b --- /dev/null +++ b/hbit-pool/tests/the_payout_tool_settles_off_the_ledger_only.rs @@ -0,0 +1,188 @@ +//! Can anything on the network choose who the manual settler pays? +//! +//! It used to be able to. `hbit-pool-payout` derived its entire recipient list +//! from an unauthenticated plain-HTTP GET of `{pool_base}/stats`, where +//! pool_base is argv[1], and read the pool's own accounting file only when that +//! answer parsed to zero rows. Whatever replied on that URL therefore chose +//! every recipient of the whole distributable balance, in transactions signed +//! with the pool wallet key. +//! +//! The endpoint could not even do the job it was there for: the tool holds the +//! exclusive settlement lock for its entire run, so the pool server is by +//! construction not running while it works. Anything that answers is, on the +//! balance of it, not the pool. +//! +//! This test runs the REAL binary against a stub that answers `/stats` with an +//! address the ledger has never heard of, and requires that address never to +//! reach the plan. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::process::Command; + +struct Stub { + base: String, + _thread: std::thread::JoinHandle<()>, +} + +/// A node that answers everything the tool needs to reach the split, and a +/// `/stats` that tries to name the recipients. +/// +/// `attacker` must be a REAL, payable address. The first version of this test +/// used a made-up string, the payable filter dropped it before the split, and +/// the test passed against the vulnerable code: it proved nothing. An attacker +/// would of course supply an address they can spend from. +fn stub(balance_hac: &'static str, attacker: String) -> Stub { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); + let base = format!("http://{}", listener.local_addr().expect("addr")); + let thread = std::thread::spawn(move || { + for s in listener.incoming() { + let Ok(mut s) = s else { break }; + let Some(target) = request_target(&mut s) else { + continue; + }; + let body = if target.starts_with("/query/latest") { + r#"{"ret":0,"height":800000,"diamond":0}"#.to_string() + } else if target.starts_with("/query/balance") { + format!(r#"{{"ret":0,"list":[{{"hacash":"{balance_hac}"}}]}}"#) + } else if target.starts_with("/stats") { + // The whole attack, in one line: a credit table naming an + // address the pool has never credited. + format!(r#"{{"credit":[["{attacker}",1000000]]}}"#) + } else { + r#"{"ret":1,"errmsg":"stub refuses everything else"}"#.to_string() + }; + let _ = write!( + s, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + content-length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = s.flush(); + } + }); + Stub { + base, + _thread: thread, + } +} + +fn request_target(s: &mut TcpStream) -> Option { + let mut r = BufReader::new(s.try_clone().ok()?); + let mut line = String::new(); + r.read_line(&mut line).ok()?; + loop { + let mut h = String::new(); + if r.read_line(&mut h).ok()? == 0 || h == "\r\n" || h == "\n" { + break; + } + } + line.split_whitespace().nth(1).map(|s| s.to_string()) +} + +/// A state file whose share window credits exactly one address. +fn write_ledger(state_file: &std::path::Path, honest: &str) { + // `order` is the share window as (worker, arrival ms). One worker only, so + // whatever the split is it can name exactly one address. + // + // TWO shares, not one. Credit is residence in the window, measured against + // an anchor the file itself carries: the last moment the pool that wrote it + // was accounting, which is the newest arrival time in here. With a single + // share the anchor IS that share's arrival, its residence is zero, and the + // tool correctly reports nothing to pay. The older share below has a full + // horizon of residence. + let body = format!( + r#"{{"window":4096,"credit_horizon_ms":600000, + "order":[["{honest}",1000],["{honest}",601000]],"banked":[], + "accepted":1,"blocks":0,"orphaned":0, + "settle_pending_txs":[],"payouts_inflight":[],"owed":[], + "immature":[]}}"# + ); + std::fs::write(state_file, body).expect("write ledger"); +} + +fn payout_binary() -> std::path::PathBuf { + // target/debug/deps/, so the binary is two levels up. + let mut p = std::env::current_exe().expect("test exe"); + p.pop(); + if p.ends_with("deps") { + p.pop(); + } + p.join(format!("hbit-pool-payout{}", std::env::consts::EXE_SUFFIX)) +} + +#[test] +fn a_stats_endpoint_cannot_choose_who_gets_paid() { + let bin = payout_binary(); + if !bin.exists() { + // cargo test builds every bin in this package before running tests, so + // this should not happen. Fail rather than pass quietly: a test that + // skips itself on the money path is worse than no test. + panic!( + "{} was not built; this test proves nothing without it", + bin.display() + ); + } + + let dir = std::env::temp_dir().join(format!("hbit-payout-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + // Three DISTINCT addresses, and the distinctness is the point. + // + // An earlier version of this test credited the pool's own wallet in the + // ledger. The tool prints "wallet =
" early in every run, so the + // "a plan was printed" assertion was satisfied by that line and the test + // passed without a plan ever existing. A miner is not the pool. + let wallet = dir.join("pool-wallet.key"); + let _pool_acc = hbit_pool::load_or_create_wallet(&wallet.to_string_lossy()); + + let honest_acc = hbit_pool::load_or_create_wallet(&dir.join("miner.key").to_string_lossy()); + let honest = honest_acc.readable().to_string(); + write_ledger(&dir.join("pool-wallet.key.state.json"), &honest); + + // A second real wallet, standing in for an address the attacker controls. + // It is a valid payable address, so nothing but the fix itself can keep it + // out of the plan. + let attacker_acc = + hbit_pool::load_or_create_wallet(&dir.join("attacker.key").to_string_lossy()); + let attacker = attacker_acc.readable().to_string(); + assert_ne!(attacker, honest, "the two wallets must differ"); + + // A balance big enough that a split really happens. + let node = stub("12:248", attacker.clone()); + + // No --commit: a dry run that prints the plan and signs nothing. + let out = Command::new(&bin) + .arg(&node.base) // , the URL that used to decide everything + .arg(&node.base) // + .arg("mainnet") + .arg(&wallet) + .output() + .expect("run hbit-pool-payout"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + assert!( + !text.contains(&attacker), + "the /stats answer named {attacker} as a recipient and the tool must never have \ + looked at it. Output was:\n{text}" + ); + // And the run must have got far enough for that to mean something: a tool + // that exited before the split would pass the assertion above for the wrong + // reason. + // The run must have produced a real plan naming a real recipient. Without + // this the assertion above passes for the wrong reason on any build that + // exits early, which is exactly how the first version of this test passed + // against the vulnerable code. + assert!( + text.contains(&honest), + "the tool never printed a plan naming the ledger's own address, so it did not reach \ + the point where recipients are chosen and this test proves nothing. Output was:\n{text}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/hbit-pool/tests/the_systemd_install_puts_files_where_the_units_look.rs b/hbit-pool/tests/the_systemd_install_puts_files_where_the_units_look.rs new file mode 100644 index 00000000..aace2de5 --- /dev/null +++ b/hbit-pool/tests/the_systemd_install_puts_files_where_the_units_look.rs @@ -0,0 +1,347 @@ +//! Does the documented install actually put the binaries where the units run +//! them from? +//! +//! It did not. `deploy/README.md` installed every binary into `/opt/hbit/` while +//! both unit files execute out of `/opt/hbit/bin/`; it built a binary called +//! `fullnode` while the node unit runs one called `hacash`; and it created +//! `/etc/hbit` without ever writing the `hacash.config.ini` the node unit passes +//! as its only argument. An operator following that section got a machine where +//! systemd retried two units for ever and nothing ever started. +//! +//! None of that is catchable by compiling anything, which is exactly why it +//! survived: the README and the unit files are two documents that have to agree +//! and nothing made them. This test is what makes them. + +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .to_path_buf() +} + +fn read(rel: &str) -> String { + let p = repo_root().join(rel); + std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} + +/// Every absolute path a unit file executes: `ExecStart=` and `ExecStartPre=`, +/// taking the program word only. +fn executed_paths(unit: &str) -> Vec { + unit.lines() + .map(str::trim) + .filter(|l| l.starts_with("ExecStart=") || l.starts_with("ExecStartPre=")) + .filter_map(|l| l.split_once('=').map(|(_, v)| v)) + .filter_map(|v| v.split_whitespace().next()) + .map(|p| p.trim_start_matches('-').to_string()) + .filter(|p| p.starts_with('/')) + .collect() +} + +/// Every file the README's install commands create, as a destination path. +/// Handles both `install ... SRC DIR/` and `install ... SRC DEST`. +fn installed_paths(readme: &str) -> Vec { + let mut out = Vec::new(); + for line in readme.lines().map(str::trim) { + if !line.contains("install ") || !line.contains("/opt/hbit") && !line.contains("/etc/hbit") + { + continue; + } + let Some(last) = line.split_whitespace().last() else { + continue; + }; + if !last.starts_with('/') { + continue; + } + let src = line.split_whitespace().rev().nth(1).unwrap_or(""); + if last.ends_with('/') { + // install SRC DIR/ -> DIR/basename(SRC) + let base = src.rsplit('/').next().unwrap_or(src); + out.push(format!("{last}{base}")); + } else { + out.push(last.to_string()); + } + } + out +} + +#[test] +fn every_binary_a_unit_runs_is_one_the_readme_installs_there() { + let readme = read("deploy/README.md"); + let installed = installed_paths(&readme); + assert!( + !installed.is_empty(), + "no install commands were found in deploy/README.md; this test must not pass by \ + finding nothing" + ); + + for unit_rel in [ + "deploy/systemd/hacash-node.service", + "deploy/systemd/hbit-pool.service", + ] { + let unit = read(unit_rel); + for exec in executed_paths(&unit) { + assert!( + installed.contains(&exec), + "{unit_rel} executes {exec}, which the systemd section of deploy/README.md \ + never puts there. It installs: {installed:#?}" + ); + } + } +} + +#[test] +fn the_node_unit_is_given_a_config_the_readme_creates() { + // The node takes its config path as its ONLY argument and exits without it. + // Nothing in the install ever wrote that file. + let unit = read("deploy/systemd/hacash-node.service"); + let arg = unit + .lines() + .map(str::trim) + .find(|l| l.starts_with("ExecStart=")) + .and_then(|l| l.split_once('=')) + .map(|(_, v)| v) + .and_then(|v| v.split_whitespace().nth(1)) + .expect("the node unit must pass a config path"); + assert!( + arg.starts_with('/'), + "expected an absolute config path, got {arg}" + ); + + let readme = read("deploy/README.md"); + assert!( + installed_paths(&readme).contains(&arg.to_string()), + "hacash-node.service starts the node with {arg}, and the systemd section of \ + deploy/README.md never creates it. The node exits immediately on every start." + ); +} + +#[test] +fn the_firewall_recipe_allows_ssh_before_it_turns_the_firewall_on() { + // ufw's default incoming policy is deny. Enabling it with no SSH rule locks + // the operator out of the machine they are in the middle of configuring. + let readme = read("deploy/README.md"); + let enable = readme + .find("ufw enable") + .expect("the README should still document enabling the firewall"); + let before = &readme[..enable]; + let ssh = before + .rfind("ufw allow OpenSSH") + .or_else(|| before.rfind("ufw allow 22")); + assert!( + ssh.is_some(), + "deploy/README.md enables ufw without allowing SSH first, which locks the operator out" + ); +} + +#[test] +fn the_documentation_link_in_each_unit_points_at_a_file_the_install_places() { + let readme = read("deploy/README.md"); + let installed = installed_paths(&readme); + for unit_rel in [ + "deploy/systemd/hacash-node.service", + "deploy/systemd/hbit-pool.service", + ] { + let unit = read(unit_rel); + for doc in unit + .lines() + .map(str::trim) + .filter(|l| l.starts_with("Documentation=file:")) + .filter_map(|l| l.strip_prefix("Documentation=file:")) + { + assert!( + installed.contains(&doc.to_string()), + "{unit_rel} documents itself at {doc}, which nothing installs. \ + `systemctl status` then names a file that is not on the machine." + ); + } + } +} + +/// The `` argument of the `hbit-pool-server` command a file starts. +/// +/// Works on all three shapes the repository ships it in: a systemd `ExecStart=` +/// line, a shell command in a script, and a docker-compose YAML argument list +/// with one argument per line. Comments are dropped first, because every one of +/// these files also TALKS about the arguments nearby. +fn share_bits_in_file(text: &str) -> Option { + let code: String = text + .lines() + .map(str::trim) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + .collect::>() + .join("\n"); + let tokens: Vec = code + .split_whitespace() + .map(|t| { + t.trim_matches(|c| c == '"' || c == '\'' || c == ',') + .to_string() + }) + // YAML list markers, and the "=" that glues ExecStart to its program. + .filter(|t| !t.is_empty() && t != "-") + .map(|t| match t.split_once('=') { + Some((k, v)) if k.starts_with("ExecStart") => v.to_string(), + _ => t, + }) + .collect(); + // Every occurrence, not the first: the usage text names the program twice, + // once with placeholders ( ...) and once with a real + // example. Only the example has a number in the share_bits position. + tokens + .iter() + .enumerate() + .filter(|(_, t)| t.ends_with("hbit-pool-server")) + // + .find_map(|(at, _)| tokens.get(at + 4)?.parse::().ok()) +} + +#[test] +fn every_shipped_command_line_uses_the_same_share_size() { + // This is a payout-fairness parameter, not a throughput knob: it decides how + // much history the 4096-share window holds, and therefore who is still in it + // when a block is found. The repository shipped 24 in the systemd unit, 20 + // in docker-compose and 20 in the VPS setup script, and the program's own + // help recommended 24 - the value the compose file carried a written + // argument against. + // + // The one true value is DEFAULT_SHARE_BITS, quoted in the pool's own usage + // text. Everything an operator can copy has to agree with it. + // The authority is the pool's own help text, asked of the real binary, so + // the constant and the shipped files are compared through the program rather + // than through a number repeated in this test. + let bin = repo_root() + .join("target/debug") + .join(format!("hbit-pool-server{}", std::env::consts::EXE_SUFFIX)); + let out = std::process::Command::new(&bin) + .arg("--help") + .output() + .unwrap_or_else(|e| panic!("run {} --help: {e}", bin.display())); + assert!(out.status.success(), "--help must succeed"); + let want = share_bits_in_file(&String::from_utf8_lossy(&out.stdout)) + .expect("the pool's own usage must carry a working example command"); + + let mut seen: Vec<(String, u32)> = Vec::new(); + for rel in [ + "deploy/systemd/hbit-pool.service", + "deploy/docker-compose.yml", + "scripts/hbit-vps-setup.sh", + ] { + if let Some(bits) = share_bits_in_file(&read(rel)) { + seen.push((rel.to_string(), bits)); + } + } + assert_eq!( + seen.len(), + 3, + "all three shipped command lines must be readable; found {seen:?}" + ); + assert!( + !seen.is_empty(), + "no shipped command line was found; this test must not pass by finding nothing" + ); + for (file, bits) in &seen { + assert_eq!( + *bits, want, + "{file} starts the pool with share_bits {bits}, but the project's value is {want}. \ + A pool started with a different share size keeps a different amount of payout \ + history, so this is a fairness setting and not a preference." + ); + } +} + +#[test] +fn no_unit_or_readme_still_points_at_a_script_that_does_not_exist() { + // Both units used to say "deploy/install.sh does it". There is no such file, + // so the one instruction an operator was given led nowhere. + let root = repo_root(); + for rel in [ + "deploy/systemd/hacash-node.service", + "deploy/systemd/hbit-pool.service", + "deploy/README.md", + ] { + let text = read(rel); + for line in text.lines() { + if let Some(at) = line.find("deploy/install.sh") { + assert!( + root.join("deploy/install.sh").exists(), + "{rel} refers to deploy/install.sh, which does not exist: {}", + &line[at..] + ); + } + } + } +} + +/// The pool archive carries `deploy/node/hacash.config.ini` (as +/// `hacash.config.ini.example`) and `scripts/hbit-vps-setup.sh` (as +/// `SETUP-POOL.sh`) side by side, so an operator reads both. Both once said +/// `fast_sync = true` "builds a chain that cannot be extended". Commit 2532814 +/// retracted that after a controlled sync with the flag ON reached the tip with +/// no errors at all. It corrected the config and missed the script, so the +/// archive shipped a retracted claim next to its own retraction. +/// +/// The refusal is right and stays hard, for the reason that survives reading the +/// node. `chain/src/insert.rs` calls `eng.minter.blk_verify` only when +/// `fast_sync` is off, and `mint/src/check/block_accept.rs` is the only place a +/// synced block's difficulty and PoW hash are checked, so a node synced with the +/// flag on took a peer's whole history on trust. Block bodies and the `tx_exist` +/// index are written either way, so `/query/transaction` and fee accounting keep +/// answering and nothing looks wrong while it happens. That is why this must +/// fail the setup rather than warn. +#[test] +fn no_shipped_file_still_gives_the_retracted_reason_for_refusing_fast_sync() { + // The exact mechanism commit 2532814 withdrew. + const RETRACTED: &str = "cannot be extended"; + + let shipped = [ + "scripts/hbit-vps-setup.sh", + "deploy/node/hacash.config.ini", + "deploy/README.md", + "docs/POOL-OPERATOR.md", + "docs/POOL-README.md", + ]; + let mut checked: Vec<&str> = Vec::new(); + for rel in shipped { + if !repo_root().join(rel).exists() { + continue; + } + checked.push(rel); + assert!( + !read(rel).contains(RETRACTED), + "{rel} still tells the operator that fast_sync {RETRACTED:?}. Commit 2532814 \ + withdrew that after a controlled sync with fast_sync = true reached the tip \ + with no errors, and this file ships in the same archive as the config that \ + carries the withdrawal." + ); + } + for must in ["scripts/hbit-vps-setup.sh", "deploy/node/hacash.config.ini"] { + assert!( + checked.contains(&must), + "{must} must be readable; this test must not pass by finding nothing" + ); + } + + // And the refusal stays hard, with the true cost named. + let setup = read("scripts/hbit-vps-setup.sh"); + let branch = setup + .split_once("fast_sync[[:space:]]*=[[:space:]]*true") + .expect("the setup script must still check for fast_sync = true") + .1; + let branch = branch + .split_once("\nelse") + .expect("the fast_sync check must still have an else branch") + .0; + assert!( + branch.contains("fail=1"), + "the setup script no longer stops on fast_sync = true. It must: a node synced \ + with it on accepted every block without checking its proof of work, and this \ + pool pays real HAC for work measured against that chain." + ); + assert!( + branch.contains("proof of work"), + "the setup script refuses fast_sync = true without saying what it really costs. \ + Name the skipped proof-of-work check, so the next reader does not fill the gap \ + with a guess the way the retracted reason was filled." + ); +} diff --git a/hbit-pool/tests/upgrade_from_an_old_state_file.rs b/hbit-pool/tests/upgrade_from_an_old_state_file.rs index 097ec2f9..d36745c4 100644 --- a/hbit-pool/tests/upgrade_from_an_old_state_file.rs +++ b/hbit-pool/tests/upgrade_from_an_old_state_file.rs @@ -18,7 +18,10 @@ use hbit_pool::{ fn tmp(tag: &str) -> String { let mut p = std::env::temp_dir(); - p.push(format!("hbit-pool-upgrade-{}-{tag}.json", std::process::id())); + p.push(format!( + "hbit-pool-upgrade-{}-{tag}.json", + std::process::id() + )); p.to_string_lossy().to_string() } @@ -70,12 +73,27 @@ fn an_old_state_file_upgrades_without_losing_a_share_a_debt_or_a_payout() { // all landed together: credit must come back in the SAME 3:2:1 ratio the // old build's headcount split would have used. let credit = load_pplns_credit(&path); - assert_eq!(credit.len(), 3, "a miner was lost from the window: {credit:?}"); + assert_eq!( + credit.len(), + 3, + "a miner was lost from the window: {credit:?}" + ); let by: std::collections::HashMap<&str, u64> = credit.iter().map(|(w, c)| (w.as_str(), *c)).collect(); - assert!(by["w-a"] > 0, "an upgraded window credits nobody: {credit:?}"); - assert_eq!(by["w-a"], 3 * by["w-c"], "3 shares must weigh 3x1: {credit:?}"); - assert_eq!(by["w-b"], 2 * by["w-c"], "2 shares must weigh 2x1: {credit:?}"); + assert!( + by["w-a"] > 0, + "an upgraded window credits nobody: {credit:?}" + ); + assert_eq!( + by["w-a"], + 3 * by["w-c"], + "3 shares must weigh 3x1: {credit:?}" + ); + assert_eq!( + by["w-b"], + 2 * by["w-c"], + "2 shares must weigh 2x1: {credit:?}" + ); // And that is exactly the split the old build made by headcount. let old_counts = [("w-a", 3u64), ("w-b", 2), ("w-c", 1)]; for (w, n) in old_counts { @@ -181,7 +199,10 @@ fn writing_the_new_ledger_into_an_old_file_keeps_the_old_share_window_readable() assert_eq!(load_owed(&path), owed); let back = load_payout_records(&path); assert_eq!(back.len(), 2); - assert_eq!(back[0].body_hex, "", "the old record still carries no bytes"); + assert_eq!( + back[0].body_hex, "", + "the old record still carries no bytes" + ); assert_eq!(back[1].body_hex, "deadbeef"); assert_eq!(load_paid_ledger(&path).get("w-c").expect("w-c").units, 41); assert_eq!( diff --git a/mainnet-configs/MAINNET-DIAMOND.md b/mainnet-configs/MAINNET-DIAMOND.md index 81c26a31..8b50e27e 100644 --- a/mainnet-configs/MAINNET-DIAMOND.md +++ b/mainnet-configs/MAINNET-DIAMOND.md @@ -81,9 +81,18 @@ Remove-Item Env:\HACASH_REPEAT16_BENCH_SECONDS ```ini connect = 127.0.0.1:8080 -supervene = 6 +; 0 = fit this machine: every logical CPU but two. +supervene = 0 ``` +`supervene = 0` is not "no threads": HACD is CPU-only, so it means the worker +counts the logical CPUs it has and takes all of them but two, keeping one for +the fullnode and one for the desktop or a co-running GPU miner's feed thread. On +a 16-core / 32-thread CPU that is 30 threads. Measured on a Ryzen 9 9950X, 1 to +32 threads is 71,728 to 1,442,210 H/s, and the 6 this file used to pin was +320,097, so a fixed number cost 78% of the machine. Set a number here only to +take less than the automatic count; a number is obeyed exactly. + The `[gpu]` section is ignored: diamonds are CPU-only and enforced as such in code. Start after the node is up: diff --git a/mainnet-configs/diaworker.mainnet.ini b/mainnet-configs/diaworker.mainnet.ini index cc9d4d8b..ad91a012 100644 --- a/mainnet-configs/diaworker.mainnet.ini +++ b/mainnet-configs/diaworker.mainnet.ini @@ -12,13 +12,30 @@ ; 3. connect = host:port of the node miner API (default 127.0.0.1:8080) ; ============================================================================ +; Your own fullnode, or a pool. Three forms are accepted: +; host:port plain HTTP, for a node on this machine or your LAN +; http://host:port the same, spelled out +; https://pool.example TLS, and what you want for any pool that is not yours +; +; Plain HTTP to somebody else's pool is not just unencrypted: a pool credits a +; share to whatever payout address the request names, so anyone on the path can +; resend your work under their address and be paid for it. The miner says so at +; startup if you point it off this machine without TLS. connect = 127.0.0.1:8080 ; Optional if the node has [server] api_token = ; api_token = ; CPU worker threads for diamond PoW. -; Prefer leaving room for the fullnode (e.g. cores - 2). -supervene = 6 +; +; 0 = fit this machine: the worker counts the logical CPUs it actually has and +; takes all of them but two, leaving one for the fullnode and one for the +; desktop or a co-running GPU miner's feed thread. On a 16-core / 32-thread CPU +; that is 30 threads and about 1.44 MH/s; this line used to say 6, which is +; 0.32 MH/s, or 22% of the machine. A file cannot count cores it has never seen, +; so it does not try. +; +; Set a number here only to take LESS than that. A number is obeyed exactly. +supervene = 0 [efficiency] mode = profit @@ -27,8 +44,14 @@ power_cost_kwh = 0.15 gpu_watts = 0 cpu_watts_per_thread = 8 hac_price = 0 -dynamic_supervene = true +; dynamic_supervene rebalances CPU threads against a GPU in the same process by +; watching the GPU/CPU nonce ratio. HACD has no GPU, so there is nothing to +; balance and the ratio is permanently 0. It also does nothing at all unless +; supervene_max > 0, which is why this said true next to a 0 cap and had no +; effect whatsoever. False is the honest value. +dynamic_supervene = false supervene_min = 1 +; 0 = no cap. The thread count above is the whole decision. supervene_max = 0 idle_start_hour = 255 idle_end_hour = 255 diff --git a/mainnet-configs/poworker.mainnet.ini b/mainnet-configs/poworker.mainnet.ini index 581e6066..d7aaa1d8 100644 --- a/mainnet-configs/poworker.mainnet.ini +++ b/mainnet-configs/poworker.mainnet.ini @@ -13,6 +13,15 @@ ; repeat=16 benchmark: set HACASH_REPEAT16_BENCH_SECONDS=30 then run poworker. ; ============================================================================ +; Your own fullnode, or a pool. Three forms are accepted: +; host:port plain HTTP, for a node on this machine or your LAN +; http://host:port the same, spelled out +; https://pool.example TLS, and what you want for any pool that is not yours +; +; Plain HTTP to somebody else's pool is not just unencrypted: a pool credits a +; share to whatever payout address the request names, so anyone on the path can +; resend your work under their address and be paid for it. The miner says so at +; startup if you point it off this machine without TLS. connect = 127.0.0.1:8080 ; 0 = let the worker pick the CPU-assist thread count supervene = 0 diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs index bab6be6c..6da10208 100644 --- a/miner-panel/src/config.rs +++ b/miner-panel/src/config.rs @@ -8,8 +8,8 @@ use app::gpu_arch::{ArchLimits, GpuVendor, normalize_profile, profile_vendor}; use crate::currency::Currency; use crate::presets::{ - CpuPreset, GpuPreset, gpu_idx_for_profile, gpu_idx_for_slug, min_work_groups_for_gpu, - resolve_panel_tuning, tuning_for_profile, + CpuPreset, GpuPreset, cpu_idx_for_supervene, gpu_idx_for_profile, gpu_idx_for_slug, + min_work_groups_for_gpu, resolve_panel_tuning, tuning_for_profile, }; pub struct PanelSettings { @@ -256,7 +256,7 @@ pub fn apply_loaded_ini( currency: Currency, ) { if let Some(sv) = loaded.supervene { - if let Some(i) = cpus.iter().position(|c| c.supervene == sv) { + if let Some(i) = cpu_idx_for_supervene(cpus, sv) { *cpu_idx = i; } } @@ -391,12 +391,23 @@ fn resolve_ini_tuning(s: &PanelSettings) -> (u32, u32, String) { (wg, us, profile) } +/// `dynamic_supervene` rebalances CPU threads against a GPU in the same process +/// by watching the ratio of GPU nonces to CPU nonces. It therefore needs a GPU +/// to be true about anything. +/// +/// Written as `false` for the diamond miner, which is CPU-only by design: there +/// the GPU nonce count is permanently zero, so the balancer sees a ratio of 0.0 +/// forever and ratchets its counter to `supervene_max` and stays there. Nothing +/// would move, because that counter drives only the reported thread count and +/// the watt estimate, not the threads. All it could do is make an honest number +/// wander. See `app::mining_runtime::maybe_adjust_supervene`. fn efficiency_section( s: &PanelSettings, throttle_work_groups: u32, gpu_watts: f64, max_temp_c: u32, supervene: u32, + dynamic: bool, ) -> String { let mode = s.mode.label(); format!( @@ -425,7 +436,9 @@ stats_file = {stats_file} gpu_watts = gpu_watts, hac_price = s.hac_price, sv = supervene, - dynamic_supervene = supervene > 0, + // `spawn_supervene` ignores the flag unless `supervene_max > 0`, so a + // true here with a zero cap would be a claim the code cannot honour. + dynamic_supervene = dynamic && supervene > 0, sv_min = if supervene > 0 { 1 } else { 0 }, max_temp = max_temp_c, idle_start = s.idle_start_hour, @@ -438,12 +451,57 @@ stats_file = {stats_file} ) } +/// Is CUDA the backend a config for this selection would really run on? +/// +/// CUDA is only a valid backend for NVIDIA GPUs, so a stale checkbox must never +/// count as CUDA for an AMD or Intel selection, or for no GPU at all. +/// +/// One function rather than one condition in `write_poworker_config` and another +/// in the panel, because the two now have to agree about something the operator +/// can see: Auto Tune is refused exactly when the file that would be written +/// says `use_cuda = true`. A predicate that drifted would either grey out a +/// button that would have worked or offer one that cannot. +pub fn cuda_backend_selected(use_cuda: bool, gpu_slug: &str, gpu_profile: &str) -> bool { + use_cuda && gpu_slug != "none" && profile_vendor(gpu_profile) == GpuVendor::Nvidia +} + +/// CPU threads this panel really asks a worker to run, from the one number the +/// operator picked. +/// +/// The panel has ONE CPU picker and three jobs behind it, and they do not want +/// the same number: +/// +/// - HACD: the CPU is the entire miner, so the pick is written as chosen. Only +/// "GPU only" needs a substitute, and the substitute is this machine, not the +/// 1 it used to become. +/// - HAC with no GPU: same situation, so the same answer, minus the substitute +/// (0 there means the operator really did choose to mine nothing). +/// - HAC with a GPU: the pick becomes CPU-ASSIST threads standing next to a card +/// worth several hundred of them, and the thread that feeds that card has to +/// compete with every one of them for a core. The ladder now reaches every +/// thread on the CPU, so without this cap an operator who set "all cores" for +/// diamonds and then switched to HAC would hand a GPU rig 30 assist threads +/// and starve the feed. Capped to `cpu_threads::cap_cpu_assist`. +/// +/// Zero survives every path where zero is a real choice. +pub fn effective_supervene(chosen: u32, gpu_slug: &str, hacd: bool) -> u32 { + if hacd { + return match chosen { + 0 => app::cpu_threads::hacd_threads(), + n => n, + }; + } + if gpu_slug == "none" { + return chosen; + } + app::cpu_threads::cap_cpu_assist(chosen) +} + pub fn write_poworker_config(path: &Path, s: &PanelSettings) -> std::io::Result<()> { let cpu_only = s.gpu.slug == "none"; - // CUDA is only a valid backend for NVIDIA GPUs; gate here so a stale checkbox never - // writes use_cuda=true for an AMD/Intel selection. - let cuda_on = s.use_cuda && !cpu_only && profile_vendor(s.gpu.profile) == GpuVendor::Nvidia; - let cpu_assist = !cpu_only && s.cpu.supervene > 0; + let cuda_on = cuda_backend_selected(s.use_cuda, s.gpu.slug, s.gpu.profile); + let supervene = effective_supervene(s.cpu.supervene, s.gpu.slug, false); + let cpu_assist = !cpu_only && supervene > 0; let (wg, us, profile) = resolve_ini_tuning(s); let body = format!( r"; Generated by miner-panel: do not edit by hand; use the panel UI. @@ -470,7 +528,7 @@ unit_size = {us} debug = 0 ", connect = s.connect, - sv = s.cpu.supervene, + sv = supervene, nonce_max = s.nonce_max, notice_wait = s.notice_wait, pool_worker = s.pool_worker, @@ -480,7 +538,10 @@ debug = 0 (wg / 2).max(1), s.gpu.watts, s.max_temp_c, - s.cpu.supervene, + supervene, + // A GPU rig has something to rebalance against; a CPU-only HAC rig + // does not, for the same reason HACD does not. + !cpu_only, ), use_ocl = if cpu_only || cuda_on { "false" } else { "true" }, use_cuda = if cuda_on { "true" } else { "false" }, @@ -499,7 +560,7 @@ debug = 0 pub fn write_diaworker_config(path: &Path, s: &PanelSettings) -> std::io::Result<()> { // HACD mining is officially CPU/full-node only. Keep this config strict so // selecting a GPU for HAC can never leak an experimental GPU path into HACD. - let supervene = s.cpu.supervene.max(1); + let supervene = effective_supervene(s.cpu.supervene, "none", true); let body = format!( r"; Generated by miner-panel (HACD / diamond mining): CPU/full-node only. connect = {connect} @@ -522,7 +583,7 @@ debug = 0 ", connect = s.connect, sv = supervene, - efficiency = efficiency_section(s, 1, 0.0, 0, supervene), + efficiency = efficiency_section(s, 1, 0.0, 0, supervene, false), ); crate::hacash_config::atomic_write_private(path, &body) } @@ -607,8 +668,11 @@ mod write_tuning_tests { .unwrap(); let s = panel_with_wg(&gpu, 2048, 128); let (wg, us, _) = resolve_ini_tuning(&s); + // A benchmark that asked for 2048 x 128 is still held to this card's + // shape. Work groups stay capped at 64; the unit_size the Profit tier + // resolves to is 128, which is what the request happened to name. assert_eq!(wg, 64); - assert_eq!(us, 64); + assert_eq!(us, 128); } #[test] @@ -714,6 +778,85 @@ mod write_tuning_tests { assert_eq!(loaded.use_cuda, Some(true)); } + /// Auto Tune hands the worker a config the tuner will refuse. + /// + /// The panel makes the two backends mutually exclusive, so pressing Run Auto + /// Tune with CUDA selected writes `use_cuda = true` / `use_opencl = false` + /// plus `benchmark_seconds = 90`. `run_block_mining_benchmark` gates only on + /// `use_opencl`, so poworker returns immediately and never patches + /// benchmark_seconds back to 0 - which is precisely the condition the panel + /// reports as "OpenCL benchmark failed" before rolling the settings back. + /// + /// The upside of the same fact: an OpenCL tune result can never be written + /// into a CUDA config through the panel, because the panel never produces a + /// config with both backends on. + #[test] + fn the_auto_tune_config_for_a_cuda_operator_asks_for_a_backend_it_turned_off() { + let gpu = gpu_presets() + .into_iter() + .find(|g| g.slug == "rtx4090") + .unwrap(); + let mut s = panel_with_wg(&gpu, 1024, 128); + s.use_cuda = true; + let path = std::env::temp_dir().join(format!( + "hacash-panel-cuda-autotune-{}.ini", + std::process::id() + )); + write_poworker_benchmark_config(&path, &s, 90).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + assert!(raw.contains("benchmark_seconds = 90"), "{raw}"); + assert!(raw.contains("use_cuda = true"), "{raw}"); + assert!( + raw.contains("use_opencl = false"), + "an Auto Tune config with both backends on would let an OpenCL tune \ + overwrite a CUDA shape: {raw}" + ); + } + + /// The predicate the Auto Tune button is greyed out by is the predicate the + /// config file is written from. + /// + /// Auto Tune measures OpenCL launch shapes only, so the button has to be + /// refused exactly when the file would say `use_cuda = true`. Checked + /// against the file itself for every preset the panel offers, so a new GPU + /// preset cannot quietly separate the two. + #[test] + fn the_button_is_refused_exactly_when_the_file_says_cuda() { + let path = std::env::temp_dir().join(format!( + "hacash-panel-cuda-predicate-{}.ini", + std::process::id() + )); + for gpu in gpu_presets() { + for use_cuda in [false, true] { + let mut s = panel_with_wg(&gpu, 1024, 128); + s.use_cuda = use_cuda; + let predicted = cuda_backend_selected(s.use_cuda, s.gpu.slug, s.gpu.profile); + write_poworker_config(&path, &s).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + predicted, + raw.contains("use_cuda = true"), + "{} with use_cuda={use_cuda}: the button and the file disagree", + gpu.slug + ); + // And CUDA on means OpenCL off, which is why the tuner never + // runs on a CUDA config in the first place. + if predicted { + assert!(raw.contains("use_opencl = false"), "{raw}"); + } + } + } + let _ = std::fs::remove_file(&path); + + // No GPU at all is not CUDA, whatever the checkbox remembers. + assert!(!cuda_backend_selected(true, "none", "nvidia_max")); + assert!(!cuda_backend_selected(true, "rx9070xt", "amd_max")); + assert!(cuda_backend_selected(true, "rtx4090", "nvidia_max")); + assert!(!cuda_backend_selected(false, "rtx4090", "nvidia_max")); + } + #[test] fn cuda_flag_ignored_for_non_nvidia_gpu() { // A stale use_cuda=true must never enable CUDA for a non-NVIDIA GPU. @@ -770,7 +913,13 @@ mod write_tuning_tests { write_diaworker_config(&path, &s).unwrap(); let raw = std::fs::read_to_string(&path).unwrap(); let _ = std::fs::remove_file(path); - assert!(raw.contains("supervene = 1")); + // `panel_with_wg` selects preset 0, "GPU only". For a CPU-only worker + // that used to be written as 1 thread; it is now this machine's count. + assert!( + raw.contains(&format!("supervene = {}", app::cpu_threads::hacd_threads())), + "{raw}" + ); + assert!(raw.contains("dynamic_supervene = false"), "{raw}"); assert!(raw.contains("gpu_watts = 0")); assert!(raw.contains("max_temp_c = 0")); assert!(raw.contains("use_opencl = false")); @@ -781,6 +930,72 @@ mod write_tuning_tests { assert!(!raw.contains("use_opencl = true")); } + /// The panel has one CPU picker and it now reaches every thread on the CPU. + /// For diamonds that is the point. For a GPU rig the same pick would become + /// CPU-assist threads competing with the thread that feeds the card, and the + /// card is worth several hundred of them. So the pick is capped on its way + /// into `poworker.config.ini` and written whole into `diaworker.config.ini`. + #[test] + fn a_gpu_rig_never_inherits_the_hacd_thread_count() { + let logical = app::cpu_threads::logical_cpus(); + let all_cores = app::cpu_threads::hacd_threads_for(logical); + let safe_assist = app::cpu_threads::cpu_assist_threads_for(logical); + + assert_eq!( + effective_supervene(all_cores, "rx9070xt", false), + safe_assist + ); + assert_eq!(effective_supervene(all_cores, "none", true), all_cores); + // A CPU-only HAC rig has no feed thread to protect, so no cap. + assert_eq!(effective_supervene(all_cores, "none", false), all_cores); + // "No CPU mining" stays a choice on every path where it is one. + assert_eq!(effective_supervene(0, "rx9070xt", false), 0); + assert_eq!(effective_supervene(0, "none", false), 0); + // ...and is not one for a CPU-only worker, which would mine nothing. + assert_eq!( + effective_supervene(0, "none", true), + app::cpu_threads::hacd_threads() + ); + // A modest pick is never raised. + assert_eq!(effective_supervene(1, "rx9070xt", false), 1); + } + + /// The cap has to survive into the file, including into `supervene_max`, + /// or the worker's dynamic rebalancer would climb straight back to the + /// uncapped number the panel refused to write on the first line. + #[test] + fn the_written_gpu_config_carries_the_capped_count_everywhere() { + let gpu = gpu_presets() + .into_iter() + .find(|g| g.slug == "rx9070xt") + .unwrap(); + let mut s = panel_with_wg(&gpu, 64, 64); + let logical = app::cpu_threads::logical_cpus(); + let all_cores = app::cpu_threads::hacd_threads_for(logical); + let safe_assist = app::cpu_threads::cpu_assist_threads_for(logical); + s.cpu = CpuPreset { + label: "test: all cores".to_string(), + supervene: all_cores, + }; + let path = + std::env::temp_dir().join(format!("hacash-panel-assist-{}.ini", std::process::id())); + write_poworker_config(&path, &s).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(path); + assert!(raw.contains(&format!("supervene = {safe_assist}")), "{raw}"); + assert!( + raw.contains(&format!("supervene_max = {safe_assist}")), + "{raw}" + ); + assert!(raw.contains("cpu_assist = true"), "{raw}"); + if all_cores > safe_assist { + assert!( + !raw.contains(&format!("supervene = {all_cores}")), + "the uncapped HACD count reached a GPU config:\n{raw}" + ); + } + } + #[test] fn benchmark_completion_marker_is_loaded() { let path = diff --git a/miner-panel/src/help_options.rs b/miner-panel/src/help_options.rs index a093fe9b..4c9fc6a5 100644 --- a/miner-panel/src/help_options.rs +++ b/miner-panel/src/help_options.rs @@ -60,7 +60,7 @@ static EN: &[HelpSection] = &[ lines: &[ "mode: max | profit | eco (also amd_profit / amd_eco aliases).", "power_cost_kwh: electricity price for profit estimates.", - "gpu_watts: override GPU power (0 = estimate from profile).", + "gpu_watts: estimated GPU power, used only where the card reports none.", "cpu_watts_per_thread: watts per CPU assist thread (default 8).", "hac_price: HAC/USD for profit pause (0 = disable revenue side).", "dynamic_supervene: auto adjust CPU assist from GPU/CPU ratio.", @@ -155,7 +155,7 @@ static EL: &[HelpSection] = &[ lines: &[ "mode: max | profit | eco.", "power_cost_kwh: τιμή ρεύματος για εκτίμηση κέρδους.", - "gpu_watts: override ισχύος GPU (0 = εκτίμηση από profile).", + "gpu_watts: εκτίμηση ισχύος GPU, μόνο όταν η κάρτα δεν τη μετρά.", "cpu_watts_per_thread: watt ανά CPU thread (default 8).", "hac_price: HAC/USD για profit pause (0 = χωρίς έσοδα).", "dynamic_supervene: αυτόματη ρύθμιση CPU assist από αναλογία GPU/CPU.", diff --git a/miner-panel/src/history.rs b/miner-panel/src/history.rs index 00d8f449..094e2b2e 100644 --- a/miner-panel/src/history.rs +++ b/miner-panel/src/history.rs @@ -276,7 +276,11 @@ mod tests { let now = 1_800_000_000_000u64; let mut text = String::new(); for i in 0..10u64 { - text.push_str(&format!("{},{}\n", now - 23 * 3_600_000 + i * GRID_MS, 1.0e9)); + text.push_str(&format!( + "{},{}\n", + now - 23 * 3_600_000 + i * GRID_MS, + 1.0e9 + )); } for i in 0..10u64 { text.push_str(&format!("{},{}\n", now - 3_600_000 + i * GRID_MS, 2.0e9)); @@ -376,9 +380,17 @@ mod tests { !persist_on_clock(&clock, &path, &series, now + 1_000), "the file is rewritten on its own interval, not every frame" ); - assert!(persist_on_clock(&clock, &path, &series, now + WRITE_INTERVAL_MS)); + assert!(persist_on_clock( + &clock, + &path, + &series, + now + WRITE_INTERVAL_MS + )); - let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().map(Result::unwrap).collect(); + let entries: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(Result::unwrap) + .collect(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].path(), path); assert!(!read_file(&path, now).is_empty()); @@ -413,6 +425,9 @@ mod tests { fn the_history_sits_beside_the_workers_own_stats_file() { let path = path_beside_stats(Path::new("/miner/work/miner-stats.json")); assert_eq!(path.file_name().unwrap(), "miner-history.csv"); - assert_eq!(path.parent(), Path::new("/miner/work/miner-stats.json").parent()); + assert_eq!( + path.parent(), + Path::new("/miner/work/miner-stats.json").parent() + ); } } diff --git a/miner-panel/src/i18n.rs b/miner-panel/src/i18n.rs index a2ea3cfa..be049da0 100644 --- a/miner-panel/src/i18n.rs +++ b/miner-panel/src/i18n.rs @@ -113,6 +113,15 @@ pub struct Strings { pub mode_eco: &'static str, pub mode_profit: &'static str, pub mode_max: &'static str, + /// Said next to the mode picker when this machine reports no power draw for + /// the selected GPU. + /// + /// Without a per-candidate watt figure the tuner divides every shape by one + /// configured constant, so hashes-per-joule and net-EUR are affine in the + /// hashrate and Eco, Profit balance and Maximum hashrate rank identically. + /// An operator who chose Eco is given Max. A mode that cannot differ must + /// not silently pretend to, and this is where the choice is made. + pub mode_no_power_sensor: &'static str, pub label_power_cost: &'static str, pub label_hac_price: &'static str, pub mining_hac: &'static str, @@ -146,13 +155,28 @@ pub struct Strings { pub paused_unprofitable: &'static str, pub stat_hashrate: &'static str, pub stat_hac_day: &'static str, + /// The power label for a figure the panel had to estimate from the + /// configured `gpu_watts`. Every translation of it must keep the word + /// "estimate": it is the only thing separating a guess from the reading + /// below, and an operator's electricity cost is built on the difference. pub stat_power: &'static str, + /// The power label for a figure the card's own sensor produced. Never used + /// for a mixed total; see `stats_poll::watts_are_measured`. + pub stat_power_measured: &'static str, + /// Detail row naming the measured GPU board draw on its own, so a rig whose + /// TOTAL is part estimate still shows the operator the part that is real. + pub stat_gpu_board_power: &'static str, pub stat_cost_day: &'static str, pub stat_efficiency: &'static str, pub stat_block_height: &'static str, pub stat_net_day: &'static str, pub stat_gpu_profile: &'static str, pub stat_diamond_best: &'static str, + /// Why the "best diamond" string looks weaker than it used to: the CPU + /// miner now runs the sha3-only half of the difficulty check before the + /// x16rs rounds, so the sampled best is drawn only from the nonces that + /// passed it. Display only; the diamonds found and submitted are the same. + pub stat_diamond_best_hint: &'static str, pub stat_cpu_threads: &'static str, pub dash_details_title: &'static str, pub dash_detail_cpu: &'static str, @@ -272,6 +296,10 @@ pub struct Strings { pub autotune_title: &'static str, pub autotune_hint: &'static str, pub btn_run_autotune: &'static str, + /// Said at the Auto Tune button when CUDA is the selected backend. The + /// tuner measures OpenCL launch shapes only, so on CUDA the button does + /// nothing and used to say so in OpenCL's words, never naming CUDA. + pub autotune_cuda_unsupported: &'static str, pub label_thermal_wg_cap: &'static str, pub profit_fixed_note: &'static str, pub label_reachability: &'static str, @@ -383,6 +411,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "Eco (less power)", mode_profit: "Profit balance (recommended)", mode_max: "Maximum hashrate", + mode_no_power_sensor: "This GPU does not report its power draw on this machine, so the three modes cannot be told apart: Eco and Profit balance choose exactly what Maximum hashrate chooses.", label_power_cost: "Power cost (/kWh):", label_hac_price: "HAC price USD (optional):", mining_hac: "HAC blocks (poworker)", @@ -417,12 +446,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "Hashrate", stat_hac_day: "HAC / day", stat_power: "Power (estimate)", + stat_power_measured: "Power (measured)", + stat_gpu_board_power: "GPU board", stat_cost_day: "Cost / day", stat_efficiency: "Efficiency", stat_block_height: "Block height", stat_net_day: "Net / day", stat_gpu_profile: "GPU profile", stat_diamond_best: "Best diamond", + stat_diamond_best_hint: "Display only: sampled after the difficulty prefilter", stat_cpu_threads: "CPU threads", dash_details_title: "Configuration & connection", dash_detail_cpu: "CPU preset", @@ -528,6 +560,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "Automatic GPU Tuning", autotune_hint: "Benchmarks safe profiles, work groups and unit size.", btn_run_autotune: "Run Auto Tune", + autotune_cuda_unsupported: "Auto Tune does not support the CUDA backend yet. It measures OpenCL launch shapes only. Switch Backend to OpenCL to tune, or set work groups and unit size by hand.", label_thermal_wg_cap: "Thermal work group cap", profit_fixed_note: "OOM fallback, dynamic CPU assist and the always on schedule are built in and always active, so they are not settings.", label_reachability: "Reachability", @@ -573,6 +606,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "Οικονομικό (λιγότερο ρεύμα)", mode_profit: "Ισορροπία κέρδους (προτείνεται)", mode_max: "Μέγιστο hashrate", + mode_no_power_sensor: "Αυτή η GPU δεν αναφέρει την κατανάλωσή της σε αυτό το μηχάνημα, οπότε οι τρεις λειτουργίες δεν ξεχωρίζουν: το Οικονομικό και η Ισορροπία κέρδους επιλέγουν ακριβώς ό,τι και το Μέγιστο hashrate.", label_power_cost: "Κόστος ρεύματος (/kWh):", label_hac_price: "Τιμή HAC USD (προαιρετικό):", mining_hac: "HAC blocks (poworker)", @@ -607,12 +641,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "Hashrate", stat_hac_day: "HAC / μέρα", stat_power: "Ρεύμα (εκτίμηση)", + stat_power_measured: "Ρεύμα (μετρημένο)", + stat_gpu_board_power: "Κάρτα GPU", stat_cost_day: "Κόστος / μέρα", stat_efficiency: "Απόδοση", stat_block_height: "Ύψος block", stat_net_day: "Καθαρό / μέρα", stat_gpu_profile: "Προφίλ GPU", stat_diamond_best: "Καλύτερο diamond", + stat_diamond_best_hint: "Μόνο ένδειξη: δείγμα μετά το προφίλτρο δυσκολίας", stat_cpu_threads: "CPU threads", dash_details_title: "Ρυθμίσεις & σύνδεση", dash_detail_cpu: "CPU preset", @@ -718,6 +755,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "Αυτόματο tuning GPU", autotune_hint: "Δοκιμάζει ασφαλή προφίλ, work groups και unit size.", btn_run_autotune: "Εκτέλεση Auto Tune", + autotune_cuda_unsupported: "Το Auto Tune δεν υποστηρίζει ακόμη το backend CUDA. Μετράει μόνο σχήματα εκτέλεσης OpenCL. Αλλάξτε το Backend σε OpenCL για tuning, ή ορίστε work groups και unit size με το χέρι.", label_thermal_wg_cap: "Θερμικό όριο work groups", profit_fixed_note: "Το OOM fallback, το δυναμικό CPU assist και η συνεχής λειτουργία είναι ενσωματωμένα και πάντα ενεργά, άρα δεν είναι ρυθμίσεις.", label_reachability: "Προσβασιμότητα", @@ -763,6 +801,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "Ekonomik (daha az güç)", mode_profit: "Kâr dengesi (önerilen)", mode_max: "Maksimum hashrate", + mode_no_power_sensor: "Bu GPU bu makinede güç tüketimini bildirmiyor, bu yüzden üç mod birbirinden ayrılamaz: Ekonomik ve Kâr dengesi tam olarak Maksimum hashrate'in seçtiğini seçer.", label_power_cost: "Elektrik maliyeti (/kWh):", label_hac_price: "HAC fiyatı USD (isteğe bağlı):", mining_hac: "HAC blokları (poworker)", @@ -797,12 +836,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "Hashrate", stat_hac_day: "HAC / gün", stat_power: "Güç (tahmini)", + stat_power_measured: "Güç (ölçülen)", + stat_gpu_board_power: "GPU kartı", stat_cost_day: "Maliyet / gün", stat_efficiency: "Verimlilik", stat_block_height: "Blok yüksekliği", stat_net_day: "Net / gün", stat_gpu_profile: "GPU profili", stat_diamond_best: "En iyi elmas", + stat_diamond_best_hint: "Yalnızca gösterim: zorluk ön filtresinden sonraki örnek", stat_cpu_threads: "CPU iş parçacığı", dash_details_title: "Yapılandırma ve bağlantı", dash_detail_cpu: "CPU ön ayarı", @@ -908,6 +950,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "Otomatik GPU ayarı", autotune_hint: "Güvenli profilleri, work group ve unit size değerlerini ölçer.", btn_run_autotune: "Auto Tune çalıştır", + autotune_cuda_unsupported: "Auto Tune henüz CUDA backend'ini desteklemiyor. Yalnızca OpenCL çalıştırma şekillerini ölçer. Ayarlamak için Backend'i OpenCL yapın veya work groups ve unit size değerlerini elle girin.", label_thermal_wg_cap: "Termal work group sınırı", profit_fixed_note: "OOM yedeği, dinamik CPU desteği ve sürekli çalışma programı yerleşiktir ve hep açıktır, yani ayar değildir.", label_reachability: "Erişilebilirlik", @@ -953,6 +996,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "节能(低功耗)", mode_profit: "利润平衡(推荐)", mode_max: "最大算力", + mode_no_power_sensor: "本机无法读取该 GPU 的功耗,因此三种模式无法区分:节能和利润平衡的选择与最大算力完全相同。", label_power_cost: "电费 (/kWh):", label_hac_price: "HAC 价格 USD (可选):", mining_hac: "HAC 区块 (poworker)", @@ -987,12 +1031,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "算力", stat_hac_day: "HAC / 天", stat_power: "功耗(估算)", + stat_power_measured: "功耗(实测)", + stat_gpu_board_power: "显卡整卡", stat_cost_day: "费用 / 天", stat_efficiency: "效率", stat_block_height: "区块高度", stat_net_day: "净收益 / 天", stat_gpu_profile: "GPU 配置", stat_diamond_best: "最佳钻石", + stat_diamond_best_hint: "仅供显示:难度预筛选之后的样本", stat_cpu_threads: "CPU 线程", dash_details_title: "配置与连接", dash_detail_cpu: "CPU 预设", @@ -1098,6 +1145,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "自动 GPU 调优", autotune_hint: "测试安全配置、work groups 和 unit size。", btn_run_autotune: "运行 Auto Tune", + autotune_cuda_unsupported: "Auto Tune 尚不支持 CUDA 后端,它只测量 OpenCL 的启动形状。请将后端切换为 OpenCL 后再调优,或手动设置 work groups 和 unit size。", label_thermal_wg_cap: "温度 work group 上限", profit_fixed_note: "OOM 回退、动态 CPU 辅助和全天候运行是内置且始终启用的,不是可调选项。", label_reachability: "可达性", @@ -1143,6 +1191,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "エコ(低消費電力)", mode_profit: "利益バランス(推奨)", mode_max: "最大ハッシュレート", + mode_no_power_sensor: "このマシンではこの GPU の消費電力を読み取れないため、3 つのモードは区別できません。エコと利益バランスは最大ハッシュレートとまったく同じ設定を選びます。", label_power_cost: "電気代 (/kWh):", label_hac_price: "HAC 価格 USD (任意):", mining_hac: "HAC ブロック (poworker)", @@ -1177,12 +1226,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "ハッシュレート", stat_hac_day: "HAC / 日", stat_power: "消費電力(推定)", + stat_power_measured: "消費電力(実測)", + stat_gpu_board_power: "GPUボード", stat_cost_day: "コスト / 日", stat_efficiency: "効率", stat_block_height: "ブロック高", stat_net_day: "純利益 / 日", stat_gpu_profile: "GPU プロファイル", stat_diamond_best: "最高ダイヤ", + stat_diamond_best_hint: "表示のみ: 難易度の事前フィルター後のサンプル", stat_cpu_threads: "CPU スレッド", dash_details_title: "設定と接続", dash_detail_cpu: "CPU プリセット", @@ -1288,6 +1340,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "GPU 自動チューニング", autotune_hint: "安全なプロファイル、work groups、unit size を計測します。", btn_run_autotune: "Auto Tune を実行", + autotune_cuda_unsupported: "Auto Tune はまだ CUDA バックエンドに対応していません。測定できるのは OpenCL の起動形状だけです。調整するにはバックエンドを OpenCL に切り替えるか、work groups と unit size を手動で設定してください。", label_thermal_wg_cap: "温度による work group 上限", profit_fixed_note: "OOM フォールバック、動的 CPU アシスト、常時稼働は組み込みで常に有効なので、設定項目ではありません。", label_reachability: "到達性", @@ -1333,6 +1386,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "Eco (menos consumo)", mode_profit: "Equilibrio de beneficio (recomendado)", mode_max: "Hashrate máximo", + mode_no_power_sensor: "Esta GPU no informa su consumo en este equipo, así que los tres modos no se distinguen: Eco y Equilibrio de beneficio eligen exactamente lo mismo que Hashrate máximo.", label_power_cost: "Coste electricidad (/kWh):", label_hac_price: "Precio HAC USD (opcional):", mining_hac: "Bloques HAC (poworker)", @@ -1367,12 +1421,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "Hashrate", stat_hac_day: "HAC / día", stat_power: "Consumo (estimado)", + stat_power_measured: "Consumo (medido)", + stat_gpu_board_power: "Placa GPU", stat_cost_day: "Coste / día", stat_efficiency: "Eficiencia", stat_block_height: "Altura de bloque", stat_net_day: "Neto / día", stat_gpu_profile: "Perfil GPU", stat_diamond_best: "Mejor diamante", + stat_diamond_best_hint: "Solo visual: muestra posterior al prefiltro de dificultad", stat_cpu_threads: "Hilos CPU", dash_details_title: "Configuración y conexión", dash_detail_cpu: "Preset CPU", @@ -1478,6 +1535,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "Ajuste automático de GPU", autotune_hint: "Mide perfiles seguros, work groups y unit size.", btn_run_autotune: "Ejecutar Auto Tune", + autotune_cuda_unsupported: "Auto Tune todavía no admite el backend CUDA. Solo mide formas de lanzamiento OpenCL. Cambia el Backend a OpenCL para ajustar, o define work groups y unit size a mano.", label_thermal_wg_cap: "Límite térmico de work groups", profit_fixed_note: "El respaldo por OOM, la ayuda dinámica de CPU y el horario continuo están integrados y siempre activos, así que no son ajustes.", label_reachability: "Accesibilidad", @@ -1523,6 +1581,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "Éco (moins de consommation)", mode_profit: "Équilibre profit (recommandé)", mode_max: "Hashrate maximum", + mode_no_power_sensor: "Ce GPU n'indique pas sa consommation sur cette machine, donc les trois modes ne se distinguent pas : Éco et Équilibre profit choisissent exactement ce que choisit Hashrate maximum.", label_power_cost: "Coût électricité (/kWh) :", label_hac_price: "Prix HAC USD (optionnel) :", mining_hac: "Blocs HAC (poworker)", @@ -1557,12 +1616,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "Hashrate", stat_hac_day: "HAC / jour", stat_power: "Puissance (estimation)", + stat_power_measured: "Puissance (mesurée)", + stat_gpu_board_power: "Carte GPU", stat_cost_day: "Coût / jour", stat_efficiency: "Efficacité", stat_block_height: "Hauteur de bloc", stat_net_day: "Net / jour", stat_gpu_profile: "Profil GPU", stat_diamond_best: "Meilleur diamant", + stat_diamond_best_hint: "Affichage seul : échantillon après le préfiltre de difficulté", stat_cpu_threads: "Threads CPU", dash_details_title: "Configuration et connexion", dash_detail_cpu: "Preset CPU", @@ -1668,6 +1730,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "Réglage automatique du GPU", autotune_hint: "Mesure les profils sûrs, les work groups et l'unit size.", btn_run_autotune: "Lancer Auto Tune", + autotune_cuda_unsupported: "Auto Tune ne prend pas encore en charge le backend CUDA. Il ne mesure que les formes de lancement OpenCL. Passez le Backend à OpenCL pour régler, ou définissez work groups et unit size à la main.", label_thermal_wg_cap: "Plafond thermique des work groups", profit_fixed_note: "Le repli OOM, l'assistance CPU dynamique et le fonctionnement continu sont intégrés et toujours actifs : ce ne sont pas des réglages.", label_reachability: "Accessibilité", @@ -1713,6 +1776,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "ประหยัด (ใช้ไฟน้อย)", mode_profit: "สมดุลกำไร (แนะนำ)", mode_max: "แฮชเรทสูงสุด", + mode_no_power_sensor: "GPU นี้ไม่รายงานกำลังไฟที่ใช้บนเครื่องนี้ ทั้งสามโหมดจึงแยกจากกันไม่ได้ โหมดประหยัดและสมดุลกำไรจะเลือกค่าเดียวกับแฮชเรทสูงสุดทุกประการ", label_power_cost: "ค่าไฟ (/kWh):", label_hac_price: "ราคา HAC USD (ไม่บังคับ):", mining_hac: "บล็อก HAC (poworker)", @@ -1747,12 +1811,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "แฮชเรท", stat_hac_day: "HAC / วัน", stat_power: "กำลังไฟ (ประมาณ)", + stat_power_measured: "กำลังไฟ (วัดจริง)", + stat_gpu_board_power: "บอร์ด GPU", stat_cost_day: "ค่าใช้จ่าย / วัน", stat_efficiency: "ประสิทธิภาพ", stat_block_height: "ความสูงบล็อก", stat_net_day: "สุทธิ / วัน", stat_gpu_profile: "โปรไฟล์ GPU", stat_diamond_best: "ไดมอนด์ที่ดีที่สุด", + stat_diamond_best_hint: "แสดงผลเท่านั้น: ตัวอย่างหลังตัวกรองความยากเบื้องต้น", stat_cpu_threads: "เธรด CPU", dash_details_title: "การตั้งค่าและการเชื่อมต่อ", dash_detail_cpu: "พรีเซ็ต CPU", @@ -1858,6 +1925,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "ปรับจูน GPU อัตโนมัติ", autotune_hint: "ทดสอบโปรไฟล์ที่ปลอดภัย work groups และ unit size", btn_run_autotune: "เริ่ม Auto Tune", + autotune_cuda_unsupported: "Auto Tune ยังไม่รองรับแบ็กเอนด์ CUDA และวัดได้เฉพาะรูปแบบการรัน OpenCL เท่านั้น เปลี่ยน Backend เป็น OpenCL เพื่อปรับจูน หรือกำหนด work groups และ unit size ด้วยตนเอง", label_thermal_wg_cap: "เพดาน work group ตามความร้อน", profit_fixed_note: "OOM fallback, CPU assist แบบไดนามิก และการทำงานตลอดเวลา ถูกฝังไว้และเปิดอยู่เสมอ จึงไม่ใช่ตัวเลือกที่ตั้งค่าได้", label_reachability: "การเข้าถึง", @@ -1903,6 +1971,7 @@ pub fn strings(lang: Lang) -> Strings { mode_eco: "Эко (меньше энергии)", mode_profit: "Баланс прибыли (рекомендуется)", mode_max: "Максимальный hashrate", + mode_no_power_sensor: "Эта видеокарта не сообщает своё энергопотребление на этой машине, поэтому три режима неразличимы: Эко и Баланс прибыли выбирают ровно то же, что и Максимальный hashrate.", label_power_cost: "Стоимость электричества (/kWh):", label_hac_price: "Цена HAC USD (необязательно):", mining_hac: "Блоки HAC (poworker)", @@ -1937,12 +2006,15 @@ pub fn strings(lang: Lang) -> Strings { stat_hashrate: "Хешрейт", stat_hac_day: "HAC / день", stat_power: "Мощность (оценка)", + stat_power_measured: "Мощность (измерено)", + stat_gpu_board_power: "Плата GPU", stat_cost_day: "Расход / день", stat_efficiency: "Эффективность", stat_block_height: "Высота блока", stat_net_day: "Чистая / день", stat_gpu_profile: "Профиль GPU", stat_diamond_best: "Лучший алмаз", + stat_diamond_best_hint: "Только для показа: выборка после предфильтра сложности", stat_cpu_threads: "Потоки CPU", dash_details_title: "Настройки и подключение", dash_detail_cpu: "Пресет CPU", @@ -2048,6 +2120,7 @@ pub fn strings(lang: Lang) -> Strings { autotune_title: "Автонастройка GPU", autotune_hint: "Замеряет безопасные профили, work groups и unit size.", btn_run_autotune: "Запустить Auto Tune", + autotune_cuda_unsupported: "Auto Tune пока не поддерживает бэкенд CUDA: он измеряет только формы запуска OpenCL. Переключите Backend на OpenCL для настройки или задайте work groups и unit size вручную.", label_thermal_wg_cap: "Тепловой предел work groups", profit_fixed_note: "Откат при нехватке памяти, динамическая помощь CPU и круглосуточный режим встроены и всегда включены, это не настройки.", label_reachability: "Доступность", @@ -2100,6 +2173,30 @@ mod tests { } } + #[test] + fn every_language_can_tell_a_measured_draw_from_an_estimated_one() { + // The two labels sit on the same row in the same place; if a language + // ever shipped them identical, an operator in that language could not + // tell the card's own 256 W from a 350 W line someone typed into an ini, + // and the whole point of reading the sensor would be lost for them. + for lang in Lang::ALL { + let s = strings(lang); + for label in [s.stat_power, s.stat_power_measured, s.stat_gpu_board_power] { + assert!( + !label.trim().is_empty(), + "{} is missing a power label", + lang.code() + ); + } + assert_ne!( + s.stat_power, + s.stat_power_measured, + "{} cannot distinguish an estimate from a measurement", + lang.code() + ); + } + } + /// Every string the payout screen can show, in the order the screen uses /// them. Kept as one list so a new language cannot ship with a blank where /// a money label belongs. @@ -2265,7 +2362,7 @@ mod tests { /// Every string the Setup screen draws for itself. A blank here is a step /// card with no title, or a choice with no explanation, in that language. - fn setup_strings(s: &Strings) -> [&'static str; 31] { + fn setup_strings(s: &Strings) -> [&'static str; 33] { [ s.setup_page_title, s.setup_page_sub, @@ -2293,6 +2390,8 @@ mod tests { s.autotune_title, s.autotune_hint, s.btn_run_autotune, + s.autotune_cuda_unsupported, + s.mode_no_power_sensor, s.label_thermal_wg_cap, s.profit_fixed_note, s.label_reachability, @@ -2320,6 +2419,121 @@ mod tests { } } + /// Every language explains why the "best diamond" reading got weaker. + /// + /// The CPU miner now prefilters on the sha3-only half of the difficulty + /// check, so the sampled best is drawn from roughly a tenth of the nonces + /// and shows fewer leading zeros than the same machine showed yesterday. A + /// language shipping this blank would show its speakers a regression with no + /// explanation next to it, on the one card they watch to decide whether the + /// miner is working. The count is asserted, not assumed. + #[test] + fn every_language_explains_the_prefiltered_best_diamond() { + assert_eq!(Lang::ALL.len(), 9, "the panel ships nine languages"); + let mut seen = std::collections::BTreeSet::new(); + for lang in Lang::ALL { + let s = strings(lang); + let hint = s.stat_diamond_best_hint; + assert!( + !hint.trim().is_empty(), + "{} never explains the prefiltered best diamond", + lang.code() + ); + assert!( + !hint.contains('\u{2014}'), + "{} uses an em dash: {hint}", + lang.code() + ); + // The card footer truncates at one line, so a newline would hide + // half the explanation rather than show it. + assert!( + !hint.contains('\n'), + "{} needs more than the one line the card footer has", + lang.code() + ); + assert_ne!( + hint, + s.stat_diamond_best, + "{} repeats the label instead of explaining it", + lang.code() + ); + seen.insert(hint); + } + assert_eq!( + seen.len(), + Lang::ALL.len(), + "two languages ship the same sentence, so one of them was never translated" + ); + } + + /// The two sentences that stop a silent no-op exist in all nine languages, + /// say the thing that matters, and are not each other. + /// + /// Both replace a mode or a button that did nothing and said nothing about + /// it, so a language that shipped a blank here would put the defect back for + /// its speakers while the test suite stayed green. + #[test] + fn every_language_says_when_a_choice_cannot_do_anything() { + assert_eq!(Lang::ALL.len(), 9, "the panel ships nine languages"); + let mut power_seen = std::collections::BTreeSet::new(); + let mut cuda_seen = std::collections::BTreeSet::new(); + for lang in Lang::ALL { + let s = strings(lang); + for text in [s.mode_no_power_sensor, s.autotune_cuda_unsupported] { + assert!( + !text.trim().is_empty(), + "{} left a no-op explanation blank", + lang.code() + ); + assert!( + !text.contains('\u{2014}'), + "{} uses an em dash: {text}", + lang.code() + ); + } + // The CUDA sentence has to name CUDA. That is the whole complaint: + // the old message talked about OpenCL and never said the word. + assert!( + s.autotune_cuda_unsupported.contains("CUDA"), + "{} must name the backend that is unsupported: {}", + lang.code(), + s.autotune_cuda_unsupported + ); + assert!( + s.autotune_cuda_unsupported.contains("OpenCL"), + "{} must name the backend that does work: {}", + lang.code(), + s.autotune_cuda_unsupported + ); + assert_ne!( + s.mode_no_power_sensor, + s.autotune_cuda_unsupported, + "{} gives two different problems the same words", + lang.code() + ); + // Untranslated copies are the other way this goes quietly wrong. + if lang != Lang::En { + let en = strings(Lang::En); + assert_ne!( + s.mode_no_power_sensor, + en.mode_no_power_sensor, + "{} did not translate the power-sensor note", + lang.code() + ); + assert_ne!( + s.autotune_cuda_unsupported, + en.autotune_cuda_unsupported, + "{} did not translate the CUDA note", + lang.code() + ); + } + power_seen.insert(s.mode_no_power_sensor); + cuda_seen.insert(s.autotune_cuda_unsupported); + } + assert_eq!(power_seen.len(), Lang::ALL.len()); + assert_eq!(cuda_seen.len(), Lang::ALL.len()); + } + #[test] fn every_language_keeps_the_two_connection_choices_apart() { // Running your own node and sending the work to a pool are opposite diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 19618908..ad76839a 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -32,6 +32,7 @@ use std::thread; use std::time::{Duration, Instant}; use app::efficiency::{EfficiencyMode, MiningStatsSnapshot}; +use app::gpu_arch::{GpuVendor, profile_vendor}; use config::{ BenchmarkConfigBackup, PanelSettings, apply_benchmark_ini, apply_loaded_ini, commit_benchmark_backup, create_benchmark_backup, interrupted_benchmark_backup, load_panel_ini, @@ -100,6 +101,20 @@ struct OpenClProbeResult { status: opencl_status::OpenClStatus, /// `None` when thermal protection is disabled or no usable GPU exists. thermal_available: Option, + /// Which vendor was asked, and whether anything on this machine reports that + /// GPU's power draw. `None` when there was no usable GPU to ask about. + /// + /// It decides whether Eco, Profit balance and Maximum hashrate are three + /// choices or one: with no per-candidate watt figure the tuner divides every + /// shape by the same configured constant, so all three rank identically and + /// an operator who picks Eco is handed Max. That is worth a line next to the + /// picker, and this is what tells the picker. + /// + /// The vendor is carried with the answer because the answer is only about + /// that vendor: an AMD card measured through the display driver says nothing + /// about the Intel card the operator switched the picker to a second later. + /// A mismatch means the panel has not asked yet, and it says nothing. + power_measured: Option<(GpuVendor, bool)>, } struct MinerApp { @@ -181,6 +196,10 @@ struct MinerApp { tab: usize, logo_texture: Option, opencl_status: opencl_status::OpenClStatus, + /// What the last probe found out about a GPU's power sensor, and which + /// vendor it asked. `None` until a probe has answered, which is why the mode + /// picker says nothing rather than guessing before then. + power_measured: Option<(GpuVendor, bool)>, opencl_probe_rx: Option>, pending_opencl_action: Option, auto_select_detected_gpu: bool, @@ -257,8 +276,23 @@ impl MinerApp { &mut use_cuda, currency, ); - if mining_kind == MiningKind::Hacd && cpus[cpu_idx].supervene == 0 { - cpu_idx = cpus.iter().position(|p| p.supervene > 0).unwrap_or(cpu_idx); + if mining_kind == MiningKind::Hacd { + // The thread count above came from `poworker.config.ini`, which is + // the only file the rest of these settings live in. A HACD operator's + // thread count is written to `diaworker.config.ini` and was never + // read back, so every restart threw the choice away and the panel + // showed a number the diamond miner was not running. Read the file + // this mode actually writes. + if let Some(sv) = load_panel_ini(&dia_config_path).supervene { + if let Some(i) = presets::cpu_idx_for_supervene(&cpus, sv) { + cpu_idx = i; + } + } + if cpus[cpu_idx].supervene == 0 { + // The smallest rung was the old substitute and it is the wrong + // one: a diamond miner that must pick something picks the machine. + cpu_idx = presets::hacd_default_idx(&cpus).unwrap_or(cpu_idx); + } } let mode = match mode_idx { 0 => EfficiencyMode::Eco, @@ -399,6 +433,7 @@ impl MinerApp { last_worker_log: String::new(), logo_texture, opencl_status, + power_measured: None, opencl_probe_rx: None, pending_opencl_action: None, auto_select_detected_gpu: !gpu_configured_in_ini, @@ -499,7 +534,20 @@ impl MinerApp { } fn cpu_label(&self, idx: usize) -> &str { - self.cpu_presets[idx].label + &self.cpu_presets[idx].label + } + + /// CPU threads the panel will really write for the currently selected + /// worker. Not always the picker's raw number: see + /// `config::effective_supervene`. Every place that shows the operator a + /// thread count goes through here, so the dashboard cannot claim one number + /// while the config file holds another. + fn configured_cpu_threads(&self) -> u32 { + config::effective_supervene( + self.cpu_presets[self.cpu_idx].supervene, + self.gpu_presets[self.gpu_idx].slug, + self.mining_kind == MiningKind::Hacd, + ) } fn gpu_label(&self, idx: usize) -> &str { @@ -606,7 +654,7 @@ impl MinerApp { MiningKind::Hacd => self.hacd_wallet.clone(), }; if kind == MiningKind::Hacd && self.cpu_presets[self.cpu_idx].supervene == 0 { - if let Some(idx) = self.cpu_presets.iter().position(|p| p.supervene > 0) { + if let Some(idx) = presets::hacd_default_idx(&self.cpu_presets) { self.cpu_idx = idx; } } @@ -630,24 +678,36 @@ impl MinerApp { let platform_id = self.platform_id; let device_id = self.device_id; let thermal_required = self.max_temp_c > 0; + // The vendor decides which sensor could answer at all: AMD through the + // display driver or rocm-smi/amd-smi, NVIDIA through nvidia-smi, Intel + // through nothing. Taken from the chosen preset, which is the same thing + // `write_poworker_config` writes into `gpu_profile`. + let vendor = profile_vendor(self.gpu_presets[self.gpu_idx].profile); let (tx, rx) = mpsc::channel(); let spawn_result = thread::Builder::new() .name("hacash-opencl-probe".to_string()) .spawn(move || { let status = opencl_status::load_opencl_status(&work_dir); + let sensor_device = if status.selection_is_usable(platform_id, device_id) { + device_id + } else { + status.recommended_device.unwrap_or(device_id) + }; let thermal_available = if thermal_required && status.has_usable_device() { - let thermal_device = if status.selection_is_usable(platform_id, device_id) { - device_id - } else { - status.recommended_device.unwrap_or(device_id) - }; - Some(app::efficiency::read_thermal_c_with_gpu("", thermal_device).is_some()) + Some(app::efficiency::read_thermal_c_with_gpu("", sensor_device).is_some()) } else { None }; + let power_measured = status.has_usable_device().then(|| { + ( + vendor, + app::efficiency::board_power_is_measurable("", sensor_device, vendor), + ) + }); let _ = tx.send(OpenClProbeResult { status, thermal_available, + power_measured, }); }); match spawn_result { @@ -695,6 +755,11 @@ impl MinerApp { require_start_checks: bool, ) -> Result<(), String> { let status = result.status; + // Recorded whatever the rest of this validation decides, including on + // the error paths below: whether the card reports watts is a fact about + // the machine, and the mode picker needs it even when the probe found a + // problem worth refusing on. + self.power_measured = result.power_measured; if !status.has_usable_device() { let detail = status.warnings.first().cloned().unwrap_or_else(|| { format!( @@ -842,6 +907,31 @@ impl MinerApp { } } + /// Does this machine report the SELECTED GPU's power draw? + /// + /// `None` until a probe has answered about the vendor now selected. That is + /// not the same as "no sensor": the panel has not asked, and a picker that + /// warned on a question it never put would be as wrong as one that stayed + /// quiet on a question it did. + pub(crate) fn selected_gpu_power_measured(&self) -> Option { + let vendor = profile_vendor(self.gpu_presets[self.gpu_idx].profile); + match self.power_measured { + Some((probed, measured)) if probed == vendor => Some(measured), + _ => None, + } + } + + /// Is CUDA the backend this panel would actually write? + /// + /// The same three conditions `write_poworker_config` applies, so the button + /// and the file can never disagree: a stale `use_cuda` on an AMD preset is + /// written as `false` and must not be reported here as CUDA. + pub(crate) fn cuda_backend_selected(&self) -> bool { + let gpu = &self.gpu_presets[self.gpu_idx]; + self.mining_kind == MiningKind::Hac + && config::cuda_backend_selected(self.use_cuda, gpu.slug, gpu.profile) + } + fn run_benchmark(&mut self) { let t = self.t(); if self.mining_settings_locked() { @@ -855,6 +945,14 @@ impl MinerApp { self.status_msg = t.no_gpu.to_string(); return; } + // Refused here rather than spent: with CUDA selected the panel writes + // `use_cuda = true` / `use_opencl = false`, poworker's tuner refuses that + // config, and every second of the wait and the settings rollback that + // followed bought nothing. The button says the same sentence. + if self.cuda_backend_selected() { + self.status_msg = t.autotune_cuda_unsupported.to_string(); + return; + } self.request_opencl_probe(OpenClAction::AutoTune); } diff --git a/miner-panel/src/node_sync.rs b/miner-panel/src/node_sync.rs index 23e3a40d..4c28abd0 100644 --- a/miner-panel/src/node_sync.rs +++ b/miner-panel/src/node_sync.rs @@ -234,7 +234,8 @@ mod tests { #[test] fn the_nodes_own_startup_refusal_is_recognised() { // The body a node inside its first 30 seconds returns. - let refusal = r#"{"ret":1,"err":"miner worker must be launched at least 30 secs after node start"}"#; + let refusal = + r#"{"ret":1,"err":"miner worker must be launched at least 30 secs after node start"}"#; assert_eq!(work_readiness_from(refusal), WorkReadiness::StartupWindow); // A node serving work. Only the first fields matter here. diff --git a/miner-panel/src/presets.rs b/miner-panel/src/presets.rs index 01275503..a58020d4 100644 --- a/miner-panel/src/presets.rs +++ b/miner-panel/src/presets.rs @@ -1,10 +1,15 @@ +use app::cpu_threads::{cpu_assist_threads_for, hacd_threads_for, logical_cpus}; use app::efficiency::{EfficiencyMode, profile_tuning}; use app::gpu_arch; use app::panel_tuning; #[derive(Clone)] pub struct CpuPreset { - pub label: &'static str, + /// What the operator reads in the picker, including the thread count. This + /// is a `String` rather than a literal because every entry below is now + /// derived from the core count of the machine the panel is running on, so + /// there is no literal to write. + pub label: String, pub supervene: u32, } @@ -22,41 +27,87 @@ pub struct GpuPreset { /// Effective OpenCL tuning written to poworker.config.ini by the panel. pub type ResolvedTuning = panel_tuning::ResolvedPanelTuning; +/// The CPU thread choices this panel offers, built from the machine's own +/// logical CPU count. +/// +/// This list used to be seven fixed numbers ending at 12, with an "Automatic" +/// entry of `(logical / 4).clamp(2, 8)`. On a 32-thread CPU that made 8 the +/// automatic answer and 12 the largest thing an operator could ask for, against +/// a measured optimum of 30. There was no way through this GUI to express a +/// modern CPU at all, so a GUI operator was capped at roughly a third of their +/// machine no matter what they clicked. +/// +/// Now every entry is a fraction of the real machine, so the list is right on a +/// 4-thread laptop and on a 128-thread Threadripper without anybody editing it +/// again. Duplicates collapse (on small CPUs several fractions land on the same +/// number), the list stays sorted, and "GPU only" stays first because it is the +/// recommended answer for a GPU rig and index 0 is what the rest of the panel +/// falls back to. pub fn cpu_presets() -> Vec { - let logical = std::thread::available_parallelism() - .map(|n| n.get() as u32) - .unwrap_or(8); - let automatic = (logical / 4).clamp(2, 8); - vec![ - CpuPreset { - label: "GPU only (recommended)", - supervene: 0, - }, - CpuPreset { - label: "Automatic CPU assist (safe)", - supervene: automatic, - }, - CpuPreset { - label: "CPU assist: low", - supervene: 2, - }, - CpuPreset { - label: "CPU assist: medium", - supervene: 4, - }, - CpuPreset { - label: "CPU assist: high", - supervene: 6, - }, - CpuPreset { - label: "CPU assist: very high", - supervene: 8, - }, - CpuPreset { - label: "CPU assist: extreme", - supervene: 12, - }, - ] + cpu_presets_for(logical_cpus()) +} + +/// `cpu_presets` for an arbitrary machine size, so the ladder is testable +/// without owning the CPU it describes. +pub fn cpu_presets_for(logical: u32) -> Vec { + let logical = logical.max(1); + let all_but_reserve = hacd_threads_for(logical); + let assist = cpu_assist_threads_for(logical); + + // (label, threads). Ordered by thread count; the two "Automatic" entries are + // named for the question they answer, because the panel writes the same + // number into a GPU rig's CPU assist and into the HACD miner that owns the + // whole CPU, and those are not the same question. + let mut rungs: Vec<(&str, u32)> = vec![ + ("Automatic: CPU assist beside a GPU", assist), + ("Automatic: all cores, best for HACD", all_but_reserve), + ("An eighth of the CPU", (logical / 8).max(1)), + ("A quarter of the CPU", (logical / 4).max(1)), + ("Half the CPU", (logical / 2).max(1)), + // Divide first: `logical * 3` would overflow a hypothetical huge count. + ("Three quarters of the CPU", (logical / 4 * 3).max(1)), + ("Every thread, leaves nothing for the node", logical), + ]; + rungs.sort_by_key(|(_, threads)| *threads); + + let mut presets = vec![CpuPreset { + label: "GPU only (recommended)".to_string(), + supervene: 0, + }]; + for (label, threads) in rungs { + if presets.iter().any(|p| p.supervene == threads) { + continue; + } + presets.push(CpuPreset { + label: format!("{label}: {threads} threads"), + supervene: threads, + }); + } + presets +} + +/// The index of the entry closest to `supervene`, for restoring a saved config. +/// +/// Nearest, not exact. The ladder is derived from the machine, so a config +/// written on one CPU and opened on another (a copied install, a VPS image, a +/// new build) will usually hold a number this ladder does not contain. Exact +/// matching sent every one of those to index 0, which is "GPU only": an operator +/// who had asked for 20 threads reopened the panel and silently had none. +pub fn cpu_idx_for_supervene(cpus: &[CpuPreset], supervene: u32) -> Option { + cpus.iter() + .enumerate() + .min_by_key(|(_, c)| c.supervene.abs_diff(supervene)) + .map(|(i, _)| i) +} + +/// The rung to move a HACD operator to when the saved selection is "GPU only". +/// +/// HACD is CPU-only, so "GPU only" is not a thing it can do and the panel has +/// always substituted something. It used to substitute the first entry with any +/// threads at all, which on the old list was 2 and on the new one is smaller +/// still. The substitute is now the automatic count: the machine. +pub fn hacd_default_idx(cpus: &[CpuPreset]) -> Option { + cpu_idx_for_supervene(cpus, app::cpu_threads::hacd_threads()).filter(|i| cpus[*i].supervene > 0) } pub fn gpu_presets() -> Vec { @@ -327,11 +378,139 @@ mod tests { .unwrap_or_else(|| panic!("unknown slug {slug}")) } + /// Max on this card is the measured optimum, not a conservative guess. + /// + /// 64 x 256 x 192 measured 28.80 MH/s at repeat 16 against 19.13 for the + /// 48 x 256 x 48 this used to resolve to, on a 0.5% noise floor, and was + /// proven byte identical to it against the CPU oracle first. The work-group + /// count is unchanged and was never the limit; the unit_size ceiling was. #[test] - fn rx9070xt_max_wg_capped() { + fn rx9070xt_max_is_the_measured_optimum() { let t = resolve_panel_tuning(&gpu("rx9070xt"), EfficiencyMode::Max); - assert_eq!(t.work_groups, 64); - assert_eq!(t.unit_size, 64); + assert_eq!((t.work_groups, t.unit_size), (64, 192)); + } + + /// The 9950X in the picker. Before this, the largest entry a GUI operator + /// could choose on this CPU was 12 and "Automatic" gave 8, against a + /// measured optimum of 30: the GUI could not express the machine at all. + #[test] + fn a_thirty_two_thread_cpu_can_finally_be_expressed_in_the_gui() { + let presets = cpu_presets_for(32); + let counts: Vec = presets.iter().map(|p| p.supervene).collect(); + assert_eq!(counts, vec![0, 4, 8, 16, 24, 30, 32]); + // The two numbers that used to be the whole story. + assert!(counts.iter().max().copied().unwrap() > 12); + assert!(counts.contains(&30), "all cores but the host reserve"); + } + + /// Every entry names its own thread count, because "high" and "extreme" are + /// not quantities and the operator is choosing a quantity. + #[test] + fn every_label_states_the_number_of_threads_it_means() { + for logical in [1u32, 2, 4, 8, 12, 16, 32, 64, 128] { + for preset in cpu_presets_for(logical) { + if preset.supervene == 0 { + continue; + } + assert!( + preset.label.contains(&preset.supervene.to_string()), + "logical={logical}: {:?} does not say {}", + preset.label, + preset.supervene + ); + } + } + } + + /// The ladder must be a ladder on any machine: sorted, no repeats, nothing + /// bigger than the CPU, and "GPU only" first because index 0 is what the + /// rest of the panel falls back to. + #[test] + fn the_ladder_is_well_formed_on_every_machine_size() { + for logical in 1..=256u32 { + let presets = cpu_presets_for(logical); + let counts: Vec = presets.iter().map(|p| p.supervene).collect(); + assert_eq!(counts[0], 0, "logical={logical}"); + assert!(counts.len() >= 2, "logical={logical}: nothing to choose"); + let mut sorted = counts.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(counts, sorted, "logical={logical}"); + assert_eq!( + *counts.last().unwrap(), + logical, + "logical={logical}: the top rung is the whole CPU" + ); + assert!( + counts.contains(&app::cpu_threads::hacd_threads_for(logical)), + "logical={logical}: no entry for the HACD default" + ); + assert!( + counts.contains(&app::cpu_threads::cpu_assist_threads_for(logical)), + "logical={logical}: no entry for the CPU assist default" + ); + } + } + + /// A config written on one machine and opened on another almost never holds + /// a number this machine's ladder contains. Exact matching sent all of those + /// to index 0, which is "GPU only": an operator who had asked for 20 threads + /// reopened the panel and found none selected. + #[test] + fn restoring_a_saved_count_lands_on_the_nearest_rung_not_on_gpu_only() { + let presets = cpu_presets_for(32); + // Exact hits still hit exactly. + for (i, preset) in presets.iter().enumerate() { + assert_eq!(cpu_idx_for_supervene(&presets, preset.supervene), Some(i)); + } + // 20 is what the shipped ryzen9 preset file asks for. It is not a rung. + let idx = cpu_idx_for_supervene(&presets, 20).unwrap(); + assert_ne!(idx, 0, "a real thread count must never restore as GPU only"); + assert_eq!(presets[idx].supervene, 16); + // A count larger than this machine lands on the top rung, not on zero. + let idx = cpu_idx_for_supervene(&presets, 999).unwrap(); + assert_eq!(presets[idx].supervene, 32); + // And zero still means zero. + assert_eq!(cpu_idx_for_supervene(&presets, 0), Some(0)); + } + + /// HACD cannot mine on "GPU only", so the panel substitutes a rung. It used + /// to substitute the smallest one that had any threads. It substitutes the + /// machine now. + #[test] + fn a_hacd_operator_pushed_off_gpu_only_lands_on_the_whole_machine() { + let presets = cpu_presets(); + let idx = hacd_default_idx(&presets).expect("a HACD rung exists"); + assert!(presets[idx].supervene > 0); + assert_eq!(presets[idx].supervene, app::cpu_threads::hacd_threads()); + // Never the smallest rung, on any machine with more than one rung of + // threads to choose between. + let smallest = presets.iter().position(|p| p.supervene > 0).unwrap(); + if presets.len() > 3 { + assert_ne!(idx, smallest); + } + } + + /// Small machines must not end up with a one-entry picker or with rungs that + /// exceed the CPU. + #[test] + fn a_small_machine_still_gets_a_usable_picker() { + let two = cpu_presets_for(2); + assert_eq!( + two.iter().map(|p| p.supervene).collect::>(), + vec![0, 1, 2] + ); + let four = cpu_presets_for(4); + assert_eq!( + four.iter().map(|p| p.supervene).collect::>(), + vec![0, 1, 2, 3, 4] + ); + // available_parallelism failing is reported as 1 logical CPU. + let one = cpu_presets_for(1); + assert_eq!( + one.iter().map(|p| p.supervene).collect::>(), + vec![0, 1] + ); } #[test] @@ -340,6 +519,19 @@ mod tests { assert!(t.work_groups >= 1024); } + /// The tuning crate drives its limit and auto-tune tests over + /// `gpu_arch::PANEL_GPU_PRESETS`. If this list and that one drift, a card + /// added here ships with a search space nobody ever checked. + #[test] + fn the_preset_list_matches_the_one_the_tuning_tests_are_driven_over() { + let here: Vec<(&str, &str, u8)> = gpu_presets() + .into_iter() + .filter(|g| g.slug != "none") + .map(|g| (g.slug, g.profile, g.vram_gb)) + .collect(); + assert_eq!(here, gpu_arch::PANEL_GPU_PRESETS.to_vec()); + } + #[test] fn opencl_gfx1201_auto_selects_rx9070xt() { let gpus = gpu_presets(); @@ -366,11 +558,17 @@ mod tests { unit_size = safe.unit_size; assert_eq!(detected.slug, "rx9070xt"); + // 64 x 128 is the Profit tier for this card. It replaced 48 x 48, which + // was doubly wrong: 48 work groups is a measured scheduling dip (32 CUs + // at 2 groups each leave a half empty tail on odd multiples of 32), and + // 48 units left the card starved on a kernel that is latency bound. assert_eq!( (profile.as_str(), work_groups, unit_size), - (safe.profile, 48, 48) + (safe.profile, 64, 128) ); assert_ne!((work_groups, unit_size), (1536, 96)); + assert_ne!(work_groups, 48, "48 work groups is a measured dip"); + assert_ne!(work_groups, 96, "96 work groups is a measured dip"); } #[test] diff --git a/miner-panel/src/stats_poll.rs b/miner-panel/src/stats_poll.rs index 6cce799c..ca7ff3f0 100644 --- a/miner-panel/src/stats_poll.rs +++ b/miner-panel/src/stats_poll.rs @@ -68,6 +68,32 @@ pub fn live_gpu_temp_c(stats: &MiningStatsSnapshot, now_ms: u64) -> Option .filter(|c| c.is_finite() && *c > 0.0 && *c < 120.0) } +/// The GPU board power the panel is allowed to call a measurement, in watts. +/// +/// `None` means no card measured it, and the panel then shows the worker's +/// configured estimate LABELLED as an estimate. It is never a zero and never the +/// last number a dead worker left behind, on exactly the rule the temperature +/// above follows, and for a sharper reason: a stale watt figure is a stale +/// electricity bill, and a zero one is a rig that appears to run for free. +pub fn live_gpu_board_power_w(stats: &MiningStatsSnapshot, now_ms: u64) -> Option { + if !snapshot_is_live(stats, now_ms) { + return None; + } + stats + .gpu_board_power_w + .filter(|w| w.is_finite() && *w > 0.0 && *w < 100_000.0) +} + +/// Whether the whole `watts` figure in this snapshot is a measurement. +/// +/// The worker decides this, not the panel: only the worker knows whether a CPU +/// assist estimate was mixed into the total. The panel additionally refuses to +/// call a stale snapshot measured, because a measurement nobody is taking any +/// more is not one. +pub fn watts_are_measured(stats: &MiningStatsSnapshot, now_ms: u64) -> bool { + stats.watts_measured && live_gpu_board_power_w(stats, now_ms).is_some() +} + /// The OOM clamp in force, or `None` when nothing was clamped. /// /// `oom_allowed_work_groups` is a COUNT of what the OOM fallback allows, not a @@ -1370,6 +1396,77 @@ mod tests { assert!(snapshot_is_live(&snapshot_at(now - 1, Some(60.0)), now)); } + fn power_snapshot_at( + taken_at_ms: u64, + watts: Option, + measured: bool, + ) -> MiningStatsSnapshot { + MiningStatsSnapshot { + gpu_board_power_w: watts, + watts_measured: measured, + updated_unix_ms: taken_at_ms, + ..Default::default() + } + } + + #[test] + fn a_power_reading_the_worker_stopped_refreshing_is_not_drawn_as_current() { + // The same failure as the stale temperature, but this one puts a number + // on an electricity bill: a worker that died an hour ago must not still + // be shown as measuring 256 W. + let now = 1_800_000_000_000u64; + let stale = STATS_STALE_AFTER.as_millis() as u64; + assert_eq!( + live_gpu_board_power_w(&power_snapshot_at(now - 1_000, Some(256.0), true), now), + Some(256.0) + ); + assert_eq!( + live_gpu_board_power_w(&power_snapshot_at(now - stale - 1, Some(256.0), true), now), + None + ); + // And with the reading gone, the total stops being called a measurement. + assert!(!watts_are_measured( + &power_snapshot_at(now - stale - 1, Some(256.0), true), + now + )); + } + + #[test] + fn an_absent_or_impossible_power_stays_absent_and_never_becomes_zero() { + let now = 1_800_000_000_000u64; + assert_eq!( + live_gpu_board_power_w(&power_snapshot_at(now, None, false), now), + None + ); + for impossible in [0.0, -30.0, f32::NAN, f32::INFINITY, 100_000.0] { + assert_eq!( + live_gpu_board_power_w(&power_snapshot_at(now, Some(impossible), true), now), + None, + "{impossible} is not a board draw" + ); + } + } + + #[test] + fn a_total_the_worker_would_not_call_measured_is_not_relabelled_here() { + // A measured card plus CPU assist: the reading is shown, the TOTAL is + // still an estimate, and the panel does not get to promote it. + let now = 1_800_000_000_000u64; + let mixed = power_snapshot_at(now, Some(256.0), false); + assert_eq!(live_gpu_board_power_w(&mixed, now), Some(256.0)); + assert!(!watts_are_measured(&mixed, now)); + // And a worker claiming a measured total without a reading behind it is + // not believed either. + assert!(!watts_are_measured( + &power_snapshot_at(now, None, true), + now + )); + assert!(watts_are_measured( + &power_snapshot_at(now, Some(256.0), true), + now + )); + } + #[test] fn relative_age_stays_readable_past_a_day() { assert_eq!(format_age_secs(5), "5s"); diff --git a/miner-panel/src/theme.rs b/miner-panel/src/theme.rs index 83089195..a1862acd 100644 --- a/miner-panel/src/theme.rs +++ b/miner-panel/src/theme.rs @@ -103,7 +103,6 @@ pub fn setup_theme(ctx: &egui::Context) { v.widgets.open.bg_stroke = Stroke::new(1.0, BORDER_ACCENT); v.widgets.open.fg_stroke = Stroke::new(1.0, TEXT); - v.slider_trailing_fill = true; // `selectable_label` paints the fill from `selection.bg_fill` and the TEXT // from `selection.stroke`. A mid-amber fill under mid-amber text was the diff --git a/miner-panel/src/ui_dashboard_tab.rs b/miner-panel/src/ui_dashboard_tab.rs index bd96a7a9..8620b6dd 100644 --- a/miner-panel/src/ui_dashboard_tab.rs +++ b/miner-panel/src/ui_dashboard_tab.rs @@ -470,7 +470,13 @@ impl MinerApp { value: &number, unit: "", sub: &format!("{}: {best}", t.stat_diamond_best), - foot: "", + // The CPU miner skips the x16rs rounds for any nonce whose + // sha3 already fails the difficulty check, so `best` is now + // sampled from ~11% of nonces and reads weaker than it used + // to. Without this line an operator sees the regression and + // not the reason. It is display only: the diamonds actually + // found and submitted are unchanged. + foot: t.stat_diamond_best_hint, foot_accent: false, spark: &[], highlight: false, @@ -554,7 +560,12 @@ impl MinerApp { }, ); - // 4. Efficiency, and the estimated draw it is divided by. + // 4. Efficiency, and the draw it is divided by - which since the card's + // own power sensor exists is a measurement on a rig that has one and + // a configured estimate on a rig that does not. The label says which, + // every time, because a 256 W reading and a 350 W guess divided into + // the same hash rate give two different efficiencies and only one of + // them is true. dashboard::kpi_card( ui, narrow, @@ -562,7 +573,7 @@ impl MinerApp { label: t.stat_efficiency, value: &format!("{:.1}", s.kh_per_j), unit: "kH/J", - sub: &format!("{:.0} W · {}", s.watts, t.stat_power.to_lowercase()), + sub: &format!("{:.0} W · {}", s.watts, self.power_label(t).to_lowercase()), foot: if is_hacd { "" } else { @@ -731,7 +742,7 @@ impl MinerApp { let (filled, centre, over_limit) = temperature_gauge(temp, self.max_temp_c); (Some(filled), centre, d.gauge_temp, over_limit) } else if is_hacd { - let configured = self.cpu_presets[self.cpu_idx].supervene; + let configured = self.configured_cpu_threads(); // The configured count is the panel's own and always true. The // active count is the worker's, so it is used only while its // snapshot is current. @@ -842,12 +853,39 @@ impl MinerApp { }); } + /// The label for the `watts` figure: "measured" only where the worker says + /// the WHOLE total came from sensors, "estimate" everywhere else. + /// + /// A rig with a measured card and CPU assist threads reads "estimate" here, + /// which is not a demotion of the measurement but the truth about the total: + /// the CPU part of it can only ever be `cpu_watts_per_thread` from the ini. + /// The measured card is shown on its own in the detail rows, so nothing real + /// is hidden by the honest label on the sum. + fn power_label(&self, t: &Strings) -> &'static str { + let now_ms = crate::stats_poll::now_unix_ms(); + if crate::stats_poll::watts_are_measured(&self.stats, now_ms) { + t.stat_power_measured + } else { + t.stat_power + } + } + + /// The detail row for a measured GPU board draw, or nothing at all where no + /// card measured one. Absent means absent all the way to the pixels: there + /// is no "0 W" row and no greyed-out placeholder, because either the sensor + /// answered or the operator has only the estimate above. + fn measured_gpu_power_row(&self, t: &Strings) -> Option<(String, String)> { + let now_ms = crate::stats_poll::now_unix_ms(); + let watts = crate::stats_poll::live_gpu_board_power_w(&self.stats, now_ms)?; + Some((t.stat_gpu_board_power.to_string(), format!("{watts:.0} W"))) + } + fn dash_limit_rows(&self, t: &Strings, d: &DashLabels) -> Vec<(String, String)> { let s = &self.stats; let is_hacd = self.mining_kind == MiningKind::Hacd; let mut rows = Vec::new(); if is_hacd { - let configured = self.cpu_presets[self.cpu_idx].supervene; + let configured = self.configured_cpu_threads(); let active = if s.active_cpu_threads > 0 { s.active_cpu_threads } else { @@ -857,6 +895,8 @@ impl MinerApp { t.stat_cpu_threads.to_string(), format!("{active} / {configured}"), )); + // HACD is CPU-only: there is no GPU board to measure, so this row + // is always the configured per-thread estimate and says so. rows.push((t.stat_power.to_string(), format!("{:.0} W", s.watts))); rows.push(( t.dash_detail_wallet.to_string(), @@ -876,7 +916,11 @@ impl MinerApp { format!("{}°C", self.max_temp_c) }, )); - rows.push((t.stat_power.to_string(), format!("{:.0} W", s.watts))); + rows.push((self.power_label(t).to_string(), format!("{:.0} W", s.watts))); + // The card's own reading, on its own row, whenever one exists. On a rig + // with CPU assist the total above is honestly labelled an estimate, and + // this is where the part that really was measured stays visible. + rows.extend(self.measured_gpu_power_row(t)); // When a cap has actually bitten, the row says which one. "1,536 / // 1,536" and "768 / 1,536 because the card got hot" are different // facts, and only the second explains a hash rate that dropped. Which @@ -914,7 +958,11 @@ impl MinerApp { // running, which the row says in words, or the worker is running threads // this panel did not ask for, and then the count it reported is the // whole of what is known. - let configured = self.cpu_presets[self.cpu_idx].supervene; + // + // The picker's number and the written number are not always the same for + // a GPU rig: CPU assist is capped so the card's feed thread keeps a core. + // This row shows what was written, because that is what is running. + let configured = self.configured_cpu_threads(); rows.push(( t.stat_cpu_threads.to_string(), if configured > 0 { diff --git a/miner-panel/src/ui_logs_tab.rs b/miner-panel/src/ui_logs_tab.rs index df447ff8..527a9757 100644 --- a/miner-panel/src/ui_logs_tab.rs +++ b/miner-panel/src/ui_logs_tab.rs @@ -75,7 +75,11 @@ impl MinerApp { .color(colors::TEXT), ); ui.add_space(2.0); - ui.label(egui::RichText::new(l.sub).size(12.0).color(colors::TEXT_MUTED)); + ui.label( + egui::RichText::new(l.sub) + .size(12.0) + .color(colors::TEXT_MUTED), + ); }); ui.add_space(16.0); @@ -209,7 +213,10 @@ mod tests { assert_eq!(stamp_column_width(&piped), 0.0); // One stamped line among many is enough to line the rest up under it. - let mixed = [line("", "banner"), line("2026-07-29 14:03:11", "[Mining] x")]; + let mixed = [ + line("", "banner"), + line("2026-07-29 14:03:11", "[Mining] x"), + ]; assert_eq!(stamp_column_width(&mixed), STAMP_W); assert_eq!(stamp_column_width(&[]), 0.0); diff --git a/miner-panel/src/ui_settings.rs b/miner-panel/src/ui_settings.rs index 3eacec8b..d5cb3e67 100644 --- a/miner-panel/src/ui_settings.rs +++ b/miner-panel/src/ui_settings.rs @@ -19,22 +19,17 @@ impl MinerApp { if self.mining_kind == MiningKind::Hacd { let col = crate::ui_settings_tab::col_width(ui); theme::field_col(ui, col, "CPU mining threads:", |ui, w| { - let selected = &self.cpu_presets[self.cpu_idx]; + // Every label already carries its own thread count, so the + // count is not appended again here. + let selected = self.cpu_presets[self.cpu_idx].label.clone(); egui::ComboBox::from_id_salt("hacd_cpu") .icon(theme::combo_chevron) - .selected_text(format!( - "{}: {} threads", - selected.label, selected.supervene - )) + .selected_text(selected) .width(w - 24.0) .show_ui(ui, |ui| { for (i, preset) in self.cpu_presets.iter().enumerate() { if preset.supervene > 0 { - ui.selectable_value( - &mut self.cpu_idx, - i, - format!("{}: {} threads", preset.label, preset.supervene), - ); + ui.selectable_value(&mut self.cpu_idx, i, preset.label.clone()); } } }); @@ -82,7 +77,7 @@ impl MinerApp { .width(w - 24.0) .show_ui(ui, |ui| { for (i, preset) in self.cpu_presets.iter().enumerate() { - ui.selectable_value(&mut self.cpu_idx, i, preset.label); + ui.selectable_value(&mut self.cpu_idx, i, preset.label.clone()); } }); }); @@ -160,6 +155,21 @@ impl MinerApp { self.apply_panel_tuning(); } } + // A mode that cannot differ must not silently pretend to. Said here, + // where the choice is made, and not only in a worker log line nobody + // reads: with no power reading every candidate is divided by the same + // configured constant, so Eco and Profit rank exactly as Max does. + // + // Only when a probe has actually answered. Before that `power_measured` + // is `None` and the panel says nothing rather than guessing. + if self.selected_gpu_power_measured() == Some(false) { + ui.add_space(6.0); + ui.label( + egui::RichText::new(format!("! {}", t.mode_no_power_sensor)) + .color(theme::colors::GOLD) + .size(11.5), + ); + } ui.add_space(14.0); self.autotune_card(ui); @@ -167,15 +177,25 @@ impl MinerApp { /// Auto Tune, in the tinted inner card the mockup gives it: what it does on /// the left, the one button on the right. + /// + /// On CUDA the button is disabled and the card says why. The tuner measures + /// OpenCL launch shapes only, so with CUDA selected pressing it wrote a + /// config poworker refuses, waited, failed, and rolled the settings back + /// without CUDA ever being mentioned. fn autotune_card(&mut self, ui: &mut egui::Ui) { let t = self.t(); + let cuda = self.cuda_backend_selected(); theme::section_card_active().show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { theme::card_title(ui, t.autotune_title, t.autotune_hint); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if theme::btn_primary(ui, t.btn_run_autotune).clicked() { + let clicked = ui + .add_enabled_ui(!cuda, |ui| theme::btn_primary(ui, t.btn_run_autotune)) + .inner + .clicked(); + if clicked { self.run_benchmark(); } if self.benchmarking { @@ -189,6 +209,14 @@ impl MinerApp { } }); }); + if cuda { + ui.add_space(6.0); + ui.label( + egui::RichText::new(format!("! {}", t.autotune_cuda_unsupported)) + .color(theme::colors::GOLD) + .size(11.5), + ); + } }); } } diff --git a/miner-panel/src/worker_log_tail.rs b/miner-panel/src/worker_log_tail.rs index efb52336..9e5a916c 100644 --- a/miner-panel/src/worker_log_tail.rs +++ b/miner-panel/src/worker_log_tail.rs @@ -517,7 +517,10 @@ mod tests { record_piped(&format!("[Mining] batch {i}")); } assert_eq!(line_count(), MAX_LINES); - assert_eq!(recent(1)[0].text, format!("[Mining] batch {}", MAX_LINES + 24)); + assert_eq!( + recent(1)[0].text, + format!("[Mining] batch {}", MAX_LINES + 24) + ); } #[test] diff --git a/miner-panel/tests/panel_tuning_integration.rs b/miner-panel/tests/panel_tuning_integration.rs index 6bb752c5..9dfac128 100644 --- a/miner-panel/tests/panel_tuning_integration.rs +++ b/miner-panel/tests/panel_tuning_integration.rs @@ -1,12 +1,21 @@ use app::efficiency::EfficiencyMode; use app::panel_tuning; +/// The panel and the worker must agree on this card's shape, end to end. +/// +/// Work groups stay capped at 64, which was never the limit. The unit_size +/// ceiling was: at a matched batch size, 64 x 256 x 192 measured 28.80 MH/s +/// against 25.85 for 256 x 256 x 48, so the same nonces arranged the other way +/// are worth about 11% less. Against the 48 x 256 x 48 the panel used to write, +/// this is 19.13 -> 28.80, +50.4% on a 0.5% noise floor, proven byte identical +/// against the CPU oracle before the number was believed. #[test] -fn rx9070xt_panel_caps_work_groups() { +fn rx9070xt_panel_writes_the_measured_shape() { let t = panel_tuning::resolve_panel_tuning("rx9070xt", "amd_performance", 16, EfficiencyMode::Max); - assert_eq!(t.work_groups, 64); - assert_eq!(t.unit_size, 64); + assert_eq!((t.work_groups, t.unit_size), (64, 192)); + assert_ne!(t.work_groups, 48, "48 work groups is a measured dip"); + assert_ne!(t.work_groups, 96, "96 work groups is a measured dip"); } #[test] diff --git a/mint/src/api/submit_transaction.rs b/mint/src/api/submit_transaction.rs index 8a4a8513..007c37a1 100644 --- a/mint/src/api/submit_transaction.rs +++ b/mint/src/api/submit_transaction.rs @@ -1,4 +1,34 @@ fn submit_transaction(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + submit_transaction_impl(ctx, req, true, |_| Ok(())) +} + +/// Submit a transaction only after a caller-owned admission check succeeds. +/// +/// The check runs synchronously in this request, after canonical transaction +/// parsing and size/fee validation, immediately before the node is asked to +/// admit the transaction. This restricted entry point deliberately disables +/// the asynchronous and txpool-only query switches so its response always +/// reflects the node's real admission result. +pub fn submit_transaction_with_pre_admission_check( + ctx: &ApiExecCtx, + req: ApiRequest, + pre_admission: F, +) -> ApiResponse +where + F: FnOnce(&ApiExecCtx) -> Rerr, +{ + submit_transaction_impl(ctx, req, false, pre_admission) +} + +fn submit_transaction_impl( + ctx: &ApiExecCtx, + req: ApiRequest, + allow_submission_switches: bool, + pre_admission: F, +) -> ApiResponse +where + F: FnOnce(&ApiExecCtx) -> Rerr, +{ let engcnf = ctx.engine.config(); let Ok(bddts) = body_data_may_hex(&req) else { return api_error("transaction body invalid"); @@ -40,8 +70,11 @@ fn submit_transaction(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { // confirm. Wait for the node's real answer, exactly as /submit/miner/success // already does for blocks. `async=true` keeps the old fire-and-forget behaviour // for callers that do not read the result. - let is_async = q_bool(&req, "async", false); - let only_insert_txpool = q_bool(&req, "only_insert_txpool", false); + let is_async = allow_submission_switches && q_bool(&req, "async", false); + let only_insert_txpool = allow_submission_switches && q_bool(&req, "only_insert_txpool", false); + if let Err(error) = pre_admission(ctx) { + return api_error(&error); + } if let Err(e) = ctx .hnoder .submit_transaction(&txpkg, is_async, only_insert_txpool) @@ -172,6 +205,35 @@ mod submit_transaction_ack_tests { (out, seen) } + fn call_checked_submit( + query: Vec<(&str, &str)>, + admission: Rerr, + ) -> (Value, Option) { + let engine: Arc = Arc::new(AckEngine { cnf: test_conf() }); + let noder = Arc::new(AckNoder { + engine: engine.clone(), + seen_async: Mutex::new(None), + }); + let ctx = ApiExecCtx { + engine, + hnoder: noder.clone(), + launch_time: 0, + miner_worker_notice_count: Arc::default(), + }; + let req = ApiRequest { + query: query + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect(), + headers: HashMap::new(), + body: tx_body(), + }; + let resp = submit_transaction_with_pre_admission_check(&ctx, req, |_| admission); + let out: Value = serde_json::from_slice(&resp.body).unwrap(); + let seen = *noder.seen_async.lock().unwrap(); + (out, seen) + } + #[test] fn a_node_rejection_is_reported_instead_of_a_bogus_ret_0() { let _setup = scoped_protocol_setup(); @@ -189,4 +251,26 @@ mod submit_transaction_ack_tests { assert_eq!(seen_async, Some(true)); assert_eq!(out["ret"], json!(0)); } -} \ No newline at end of file + + #[test] + fn checked_submit_rejects_before_the_node_can_admit_the_transaction() { + let _setup = scoped_protocol_setup(); + let (out, seen_async) = + call_checked_submit(vec![], Err("HPAY network binding mismatch".into())); + assert_eq!(seen_async, None); + assert_eq!(out["ret"], json!(1)); + assert_eq!(out["err"], json!("HPAY network binding mismatch")); + } + + #[test] + fn checked_submit_is_always_synchronous_even_if_switches_are_supplied() { + let _setup = scoped_protocol_setup(); + let (out, seen_async) = call_checked_submit( + vec![("async", "true"), ("only_insert_txpool", "true")], + Ok(()), + ); + assert_eq!(seen_async, Some(false)); + assert_eq!(out["ret"], json!(1)); + assert_eq!(out["err"], json!("tx fee purity too low")); + } +} diff --git a/mint/src/api/transaction.rs b/mint/src/api/transaction.rs index 308c5c02..24af93d7 100644 --- a/mint/src/api/transaction.rs +++ b/mint/src/api/transaction.rs @@ -500,6 +500,45 @@ mod transaction_build_tests { Some("create_transaction_invalid_gas_max") ); } + + #[test] + fn included_transaction_info_binds_the_exact_block_hash() { + let _guard = install_protocol_setup(); + let req = ApiRequest { + query: HashMap::from([("body".to_owned(), "true".to_owned())]), + body: json!({ + "tx_type": 3, + "main_address": "1AVRuFXNFi3rdMrPH4hdqSgFrEBnWisWaS", + "fee": "1:244", + "gas_max": 17, + "actions": [] + }) + .to_string() + .into_bytes(), + ..ApiRequest::default() + }; + let response = transaction_build_inner(&req); + let built: Value = serde_json::from_slice(&response.body).unwrap(); + let bytes = hex::decode(built["body"].as_str().unwrap()).unwrap(); + let (transaction, _) = protocol::transaction::transaction_create(&bytes).unwrap(); + let block = BlockV1::default(); + + let rendered = render_tx_info( + transaction.as_read(), + Some(block.as_read()), + block.height().uint(), + "fin", + false, + false, + false, + false, + ); + + assert_eq!( + rendered["block"]["hash"].as_str(), + Some(block.hash().to_hex().as_str()) + ); + } } fn transaction_check(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { @@ -713,6 +752,7 @@ fn render_tx_info( data.insert( "block".to_owned(), json!({ + "hash": blkobj.hash().to_hex(), "height": txblkhei, "timestamp": blkobj.timestamp().uint(), }), diff --git a/scripts/hbit-vps-setup.sh b/scripts/hbit-vps-setup.sh index 6e8229ec..8d64a7f7 100644 --- a/scripts/hbit-vps-setup.sh +++ b/scripts/hbit-vps-setup.sh @@ -37,8 +37,44 @@ if [ "${avail_gb:-0}" -ge 10 ]; then ok "${avail_gb} GB free"; else bad "only ${ step "3. The node config" if [ ! -f hacash.config.ini ]; then cp hacash.config.ini.example hacash.config.ini + # The example is written for Docker, where the pool is in a DIFFERENT + # container and has to cross the network to reach the node - so it binds + # 0.0.0.0 and carries an api_token, because the node refuses to serve a + # non-loopback address without one. + # + # Here the pool is on this same machine. Loopback is the right answer and + # needs no token, and leaving the example's placeholder token in place would + # be worse than useless: the node would serve happily, the pool would send + # no token, every request would come back 401, and the pool would report a + # healthy node as down with nothing saying why. + sed -i 's/^[[:space:]]*bind[[:space:]]*=.*/bind = 127.0.0.1/' hacash.config.ini + sed -i 's/^[[:space:]]*api_token[[:space:]]*=.*/; api_token =/' hacash.config.ini say " created hacash.config.ini from the example" + say " set bind = 127.0.0.1 and cleared api_token: node and pool are on" + say " this one machine, so the API belongs on loopback" fi +bind=$(sed -n 's/^[[:space:]]*bind[[:space:]]*=[[:space:]]*//p' hacash.config.ini | head -1) +token=$(sed -n 's/^[[:space:]]*api_token[[:space:]]*=[[:space:]]*//p' hacash.config.ini | head -1) +case "$bind" in +127.0.0.1 | ::1 | localhost | "") + ok "the node API is on loopback (bind = ${bind:-127.0.0.1})" + ;; +*) + if [ -z "$token" ]; then + bad "bind = $bind is not loopback and api_token is empty" + say " The node will print one line and never listen, while the process" + say " keeps running and syncing. The pool would wait for it for ever." + say " Set bind = 127.0.0.1, or set a long random api_token." + fail=1 + else + bad "bind = $bind with an api_token, but this pool sends no token" + say " The node will serve and answer 401 to every request the pool" + say " makes, and the pool will report your healthy node as down." + say " On one machine, set bind = 127.0.0.1 and leave api_token empty." + fail=1 + fi + ;; +esac reward=$(sed -n 's/^[[:space:]]*reward[[:space:]]*=[[:space:]]*//p' hacash.config.ini | head -1) if [ -z "$reward" ]; then bad "reward is empty in hacash.config.ini" @@ -51,7 +87,14 @@ else say " Check that address is yours before going further." fi if grep -qE '^[[:space:]]*fast_sync[[:space:]]*=[[:space:]]*true' hacash.config.ini; then - bad "fast_sync = true builds a chain that cannot be extended; set it to false" + # Hard FAIL, not a warning. chain/src/insert.rs runs the minter block gate + # only when fast_sync is off, and mint/src/check/block_accept.rs is the only + # place a synced block's difficulty and PoW hash are ever checked. + bad "fast_sync = true accepts synced blocks without checking their proof of work" + say " Whatever history a peer sends becomes this node's chain unchecked, and" + say " the pool would credit and pay miners real HAC for work measured against" + say " it. Nothing looks broken while it happens: the node still reaches the" + say " tip and still answers every query. Set fast_sync = false." fail=1 else ok "fast_sync is not enabled" diff --git a/scripts/mining-amd/diaworker.amd.ini.example b/scripts/mining-amd/diaworker.amd.ini.example index d11f790e..e0263b65 100644 --- a/scripts/mining-amd/diaworker.amd.ini.example +++ b/scripts/mining-amd/diaworker.amd.ini.example @@ -2,15 +2,27 @@ ; OpenCL and GPU tuning apply to HAC poworker, not HACD. ; Requires [diamondminer] enable = true in hacash.config.ini. +; Your own fullnode, or a pool. Three forms are accepted: +; host:port plain HTTP, for a node on this machine or your LAN +; http://host:port the same, spelled out +; https://pool.example TLS, and what you want for any pool that is not yours +; +; Plain HTTP to somebody else's pool is not just unencrypted: a pool credits a +; share to whatever payout address the request names, so anyone on the path can +; resend your work under their address and be paid for it. The miner says so at +; startup if you point it off this machine without TLS. connect = 127.0.0.1:8080 -supervene = 4 +; 0 = fit this machine: all logical CPUs but two. Set a number only to take less. +supervene = 0 [efficiency] mode = profit power_cost_kwh = 0.15 cpu_watts_per_thread = 8 -dynamic_supervene = true +; No GPU in this process, so there is no GPU/CPU ratio to rebalance against. +dynamic_supervene = false supervene_min = 1 +; 0 = no cap. supervene_max = 0 benchmark_seconds = 0 diff --git a/scripts/mining-amd/poworker.amd.ini.example b/scripts/mining-amd/poworker.amd.ini.example index 097fc128..2cb3ce0f 100644 --- a/scripts/mining-amd/poworker.amd.ini.example +++ b/scripts/mining-amd/poworker.amd.ini.example @@ -1,6 +1,15 @@ -; HAC block miner — AMD OpenCL GPU-only (efficiency tuned) +; HAC block miner - AMD OpenCL GPU-only (efficiency tuned) ; Copy via INSTALL-CONFIGS.bat or CONFIGURE-MINING.bat +; Your own fullnode, or a pool. Three forms are accepted: +; host:port plain HTTP, for a node on this machine or your LAN +; http://host:port the same, spelled out +; https://pool.example TLS, and what you want for any pool that is not yours +; +; Plain HTTP to somebody else's pool is not just unencrypted: a pool credits a +; share to whatever payout address the request names, so anyone on the path can +; resend your work under their address and be paid for it. The miner says so at +; startup if you point it off this machine without TLS. connect = 127.0.0.1:8080 supervene = 0 nonce_max = 4294967295 diff --git a/scripts/mining-nvidia/COLAB-T4.md b/scripts/mining-nvidia/COLAB-T4.md index 94258d70..e769a257 100644 --- a/scripts/mining-nvidia/COLAB-T4.md +++ b/scripts/mining-nvidia/COLAB-T4.md @@ -1,88 +1,162 @@ -# CUDA Phase 1 — Google Colab T4 - -**Goal:** prove CUDA kernels + `poworker --features cuda` on a real NVIDIA GPU (T4) without a local card. - -**Rules:** no GitHub push until this smoke is **PASS** and you keep the log files. - -## Free tier vs full - -| Mode | Command | Time (typical) | Enough for Phase 1? | -|------|---------|----------------|---------------------| -| **FREE (default)** | `bash scripts/mining-nvidia/colab_cuda_smoke.sh` | ~10–25 min | **Yes** — CUDA kernels + tests | -| FULL (optional) | `COLAB_FULL=1 bash ...` | 30–90+ min | Extra: poworker binary | - -**Do not** run the old full `cargo build --release --bin poworker` on free Colab with workspace `lto = true` — it can look like it never finishes and the session dies. - -Heartbeat lines every 60s (`still working...`) mean it is alive. - -## PASS checklist (FREE default) - -| # | Check | How | -|---|--------|-----| -| 1 | GPU visible | `nvidia-smi` (T4 OK) | -| 2 | Toolkit | `nvcc --version` | -| 3 | Unit tests | `cargo test -p x16rs-cuda --features cuda` exit 0 | -| 4 | Genesis + CPU/GPU + batch | included in those tests | -| 5 | Summary | `result=PASS` in `colab-results/latest-summary.txt` | - -poworker release build is **optional** (`COLAB_FULL=1`). - -## Colab steps - -1. Open [Google Colab](https://colab.research.google.com/). -2. **Runtime → Change runtime type → T4 GPU**. -3. **Do NOT upload the full 70GB folder.** Almost all of that is `target/` (Rust build cache), not source. - - On your PC run: - ```powershell - cd C:\Users\KQHEX\Documents\hacash-fullnodedev - powershell -NoProfile -ExecutionPolicy Bypass -File scripts\mining-nvidia\pack-colab-slim.ps1 - ``` - - Upload only: - `scripts\mining-nvidia\colab-upload\hacash-fullnodedev-colab-slim.zip` - (usually tens of MB, not GB) - - In Colab: - ```bash - !unzip -q hacash-fullnodedev-colab-slim.zip -d /content - %cd /content/hacash-fullnodedev - !bash scripts/mining-nvidia/colab_cuda_smoke.sh - ``` - - **Or** (if Phase 1 is already on GitHub): `git clone --depth 1` your fork (small) — Colab rebuilds `target/` on the VM. -4. Run the notebook cells, or: - ```bash - cd /content/fullnodedev - chmod +x scripts/mining-nvidia/colab_cuda_smoke.sh - bash scripts/mining-nvidia/colab_cuda_smoke.sh - ``` -5. First compile is slow (often 15–40 min). Keep the tab open. -6. Download `scripts/mining-nvidia/colab-results/*` before the session dies. - -## Evidence files - -After a run: - -- `scripts/mining-nvidia/colab-results/smoke-*.log` -- `scripts/mining-nvidia/colab-results/latest-summary.txt` (`result=PASS` or `FAIL`) - -## FAIL common causes - -| Symptom | Fix | -|---------|-----| -| No `nvidia-smi` | Enable T4 GPU runtime | -| `CUDA Toolkit not found` | Script sets `/usr/local/cuda`; re-run after GPU attach | -| Kernels not compiled | Confirm cargo warning: `Using CUDA Toolkit at ...` | -| Rust edition 2024 error | Update rustup stable in the script/session | -| Session timeout | Re-run; use Colab Pro if free tier kills long builds | - -## After PASS - -1. Keep logs offline. -2. Only then update docs / RC notes with real T4 evidence. -3. Still **no production pool/Stratum claim** from Phase 1 alone. -4. Still **no push** until you decide the tree is ready (your rule). - -## Related - -- `colab_cuda_smoke.sh` — automated checks -- `colab_cuda_smoke.ipynb` — Colab notebook -- `HANDOFF-RTX.md` — Windows RTX checklist -- `TEST-CUDA-GPU.bat` — Windows equivalent of tests +# CUDA on a Google Colab T4 + +**Runtime -> Change runtime type -> T4 GPU** before anything else. Without it +every cell below either errors or measures nothing. + +## The three things you can run, in the order they should be run + +| # | What | Command | Proves | +| - | ---- | ------- | ------ | +| 1 | **Byte-equivalence gate** | `bash scripts/mining-nvidia/colab_cuda_gate.sh` | Every hash the card computes equals `x16rs::block_hash`, byte for byte, at repeat 1/4/8/16 across three launch shapes and the production shape. Then that the gate itself catches kernels broken on purpose. | +| 2 | Crate smoke | `bash scripts/mining-nvidia/colab_cuda_smoke.sh` | The `x16rs-cuda` suite: the genesis vector, the differential tests, and the pool share list's bookkeeping (overflow, counter isolation, readback bounds). | +| 3 | Pool end to end | `scripts/mining-nvidia/colab_cuda_pool_e2e.md` | The card is CREDITED in proportion to the work it does, measured against a single CPU thread in the same PPLNS window at real mainnet difficulty. | + +**1 comes first and is not optional.** A hashrate from an unproven kernel is a +number, not a result. `colab_cuda_pool_e2e.md` runs the gate as its Cell 2 and +refuses to reach the measurement cells if it fails. + +## What the gate costs and how it survives a dying session + +| | | +| --- | --- | +| Wall clock, full run | roughly 25 to 45 minutes on a free T4, dominated by four cargo builds | +| Wall clock, `SKIP_FAULTS=1` | roughly a quarter of that, and proves a quarter as much | +| Resumable | yes. Every step writes a marker under `target/gate-state//`; a re-run skips what already passed | +| Progress | a heartbeat with elapsed time every 60 seconds during a compile | +| Evidence | `scripts/mining-nvidia/colab-results/gate-*.log` and `latest-gate-summary.txt` | + +The fingerprint covers the commit, the kernel sources and the gate sources, so +editing any of them invalidates the markers by itself. `RESUME=0` forces a full +redo. + +### Env knobs + +| Variable | Effect | +| --- | --- | +| `SKIP_FAULTS=1` | equivalence only, no fault injection. Result becomes `PASS-UNPROVEN` | +| `ALLOW_RACE_MISS=1` | let fault B (a deleted barrier, so a data race) go uncaught. Result becomes `PASS-RACE-NOT-REPRODUCED` | +| `RESUME=0` | ignore markers, redo every step | +| `PROD_WG`, `PROD_UNIT`, `PROD_BATCHES` | production launch shape and how many windows of it. Each window costs a full CPU oracle over `PROD_WG * 256 * PROD_UNIT` nonces | +| `HEADERS` | corpus headers in the exhaustive pass (default 4) | + +## Reading the result + +`scripts/mining-nvidia/colab-results/latest-gate-summary.txt` is one `key=value` +per line and is the thing to keep. The `result` line is one of: + +| `result=` | Meaning | +| --- | --- | +| `PASS` | The kernels match the CPU byte for byte AND the gate caught all three deliberately broken kernels. This is the only clean pass | +| `PASS-RACE-NOT-REPRODUCED` | The kernels match. The arithmetic faults were caught; the data-race fault did not reproduce on this card and was waived. Weaker; quote the string, not the word PASS | +| `PASS-UNPROVEN` | The kernels match, but `SKIP_FAULTS=1` meant the gate was never shown to be able to fail here | +| `FAIL` | Read `reason=`. Either the kernels disagree with the CPU (stop, do not mine), or the device could not be opened (nothing was compared), or a fault went uncaught (the gate cannot be trusted) | + +The summary also carries `commit=`, so a log always says which code it is about. + +## Getting the code onto Colab + +### Option A: clone (needs the gate to be pushed) + +```bash +git clone --depth 1 -b feat/pool-directory-cuda-ptx-panel \ + https://github.com/Moskyera/fullnodedev.git /content/fullnodedev +cd /content/fullnodedev +git fetch --depth 1 origin feat/pool-directory-cuda-ptx-panel +git reset --hard FETCH_HEAD +git log --oneline -1 +``` + +The fetch and reset are not redundant. `git clone` is skipped when the directory +already exists, so a session that survived a runtime restart silently re-tests an +old commit. + +**Check the clone actually contains the gate, not just the right commit.** The +CUDA half of the gate is newer than the last pushed commit, and at `3248146` none +of it is there: + +```bash +grep -l CudaBackend app/src/x16rs_gate.rs +grep -l -- --backend src/bin/x16rs_gate.rs +grep -l X16RS_CUDA_KERNEL_DIR x16rs-cuda/build.rs +grep -l X16RS_H_BLAKE_INIT x16rs/opencl/x16rs.cl +test -f scripts/mining-nvidia/colab_cuda_gate.sh && echo gate-runner-ok +``` + +All five must print. Cell 1 of `colab_cuda_pool_e2e.md` does this and stops with +the list of what is missing. + +`X16RS_H_BLAKE_INIT` deserves its own sentence. It is blake's initialisation +vector, exported from `x16rs.cl` so `block_miner.cu` reads it instead of carrying +its own copy. Without it, fault C (flip one bit of that IV) compiles to +byte-identical PTX, and the CUDA gate returns PASS for a kernel that is broken on +purpose. The whole fault-injection proof rests on it. + +### Option B: upload a zip (works with nothing pushed) + +This is the only way to run the gate while the CUDA work is still unpushed. + +On the Windows box: + +```powershell +cd C:\Users\KQHEX\Documents\hacash-fullnodedev +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\mining-nvidia\pack-colab-slim.ps1 +``` + +The packer refuses to build a zip whose tree predates the gate, and writes +`COLAB-PACK-STAMP.txt` with the commit it was packed from. Upload only +`scripts\mining-nvidia\colab-upload\hacash-fullnodedev-colab-slim.zip` (tens of +MB, not the 70 GB working directory, almost all of which is `target/`). + +In Colab: + +```bash +!unzip -q hacash-fullnodedev-colab-slim.zip -d /content +%cd /content/hacash-fullnodedev +!cat COLAB-PACK-STAMP.txt +!bash scripts/mining-nvidia/colab_cuda_gate.sh +``` + +Note the directory: the zip unpacks to `/content/hacash-fullnodedev`, while a +clone lands in `/content/fullnodedev`. A zip has no `.git`, so the gate log will +say `commit=not-a-git-checkout`; the pack stamp is what identifies it, so keep +the two together. + +## Build profile + +Every script here exports the same reduced release profile: + +``` +CARGO_PROFILE_RELEASE_LTO=false +CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +CARGO_PROFILE_RELEASE_OPT_LEVEL=2 +``` + +The workspace ships `lto = "thin"` with `codegen-units = 1`, which on a free +Colab VM is slow and can be killed for memory. Two consequences worth knowing: + +- Cargo keys its build cache on the profile, so **every cell must use the same + one** or the whole dependency tree compiles twice. `colab_cuda_pool_e2e.md` + pins it once in Cell 1 for exactly this reason. +- Absolute hashrates from a Colab run are not comparable with a shipping-profile + build. The GPU kernels are nvcc `-O3` either way, so the CUDA number moves + little; CPU-side numbers move more. Ratios measured within one run are + unaffected, because both sides were built the same way. + +## FAIL: common causes + +| Symptom | Cause and fix | +| --- | --- | +| `no nvidia-smi` | Not a GPU runtime. Runtime -> Change runtime type -> T4 GPU | +| gate exits 4, "the installed NVIDIA driver is older than the CUDA runtime ... (code 35)" | No driver attached. Same fix. This is a clean exit with a message, not a crash | +| gate exits 1, "NO CUDA kernels" | `x16rs-cuda/build.rs` did not find nvcc at build time, so `cfg(cuda_available)` is unset and every device call returns `NotCompiled`. Set `CUDA_PATH=/usr/local/cuda` and rebuild. The gate refuses to run rather than report a pass over zero hashes | +| `cargo test` green but suspiciously fast | `ocl` and `cuda` are optional features. A plain `cargo test` compiles NEITHER backend, and `gpu_share_list_tests` is `#[cfg(all(test, cuda_available))]`, so without nvcc it is not compiled at all and its absence is silent. Check test NAMES in the output, not the pass count | +| Rust edition 2024 error | Update the stable toolchain in the session | +| Session dies mid-build | Re-run. The gate resumes from its markers; a cargo build resumes from `target/` if the VM survived | + +## Related files + +- `colab_cuda_gate.sh` and `colab_cuda_pool_e2e.md` (start here) +- `colab_cuda_smoke.sh`, `colab_cuda_smoke.ipynb` (the crate suite) +- `pack-colab-slim.ps1` (the zip for Option B) +- `HANDOFF-RTX.md` (Windows RTX checklist) +- `TEST-CUDA-GPU.bat` (Windows equivalent of the crate tests) diff --git a/scripts/mining-nvidia/colab_cuda_gate.sh b/scripts/mining-nvidia/colab_cuda_gate.sh new file mode 100644 index 00000000..2589810b --- /dev/null +++ b/scripts/mining-nvidia/colab_cuda_gate.sh @@ -0,0 +1,331 @@ +#!/usr/bin/env bash +# Byte-equivalence gate for the CUDA kernels, on a real NVIDIA GPU (Colab T4 or +# any Linux NVIDIA box). +# +# This is the thing to run BEFORE believing any CUDA hashrate. It proves the +# card's hashes equal x16rs::block_hash byte for byte, at repeat 1/4/8/16, with +# EVERY hash in each window compared. Then, unless SKIP_FAULTS=1, it proves the +# gate can FAIL, by rebuilding the CUDA kernels against three trees with +# deliberate defects in them and requiring the gate to catch them. +# +# A gate that has only ever been seen to pass proves nothing about the kernel; +# it proves the gate is quiet. +# +# bash scripts/mining-nvidia/colab_cuda_gate.sh +# +# Exit 0 = the card agrees with the CPU AND the gate demonstrably catches +# defects. Anything else is a failure a script can see. +# +# RESUMABLE. Every step that costs minutes writes a marker under +# target/gate-state// when it succeeds, and a re-run skips it. The +# fingerprint covers the git commit, the kernel sources and the gate sources, so +# touching any of them starts over by itself. A Colab T4 session that dies half +# way through therefore costs you the step it was on, not the whole run. +# RESUME=0 forces everything to be redone. +# +# Env: +# SKIP_FAULTS=1 clean run only (about 4x faster, and proves 4x less) +# ALLOW_RACE_MISS=1 let fault B (a deleted barrier, i.e. a data race) go +# uncaught without failing the run. Read the note at step 2 +# before using this: it downgrades the result string, on +# purpose, so a run that used it can never be quoted as a +# clean PASS. +# RESUME=0 ignore previous markers and redo every step +# PROD_WG production-shape work_groups (default 48) +# PROD_UNIT production-shape unit_size (default 48) +# PROD_BATCHES production windows (default 1; each costs a full CPU +# oracle over work_groups*256*unit_size nonces) +# HEADERS corpus headers (default 4) + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +SKIP_FAULTS="${SKIP_FAULTS:-0}" +ALLOW_RACE_MISS="${ALLOW_RACE_MISS:-0}" +RESUME="${RESUME:-1}" +PROD_WG="${PROD_WG:-48}" +PROD_UNIT="${PROD_UNIT:-48}" +PROD_BATCHES="${PROD_BATCHES:-1}" +HEADERS="${HEADERS:-4}" + +# Faults that are races rather than wrong arithmetic. A race is caught because +# the hardware happened to interleave badly, so "not caught" on some card is a +# statement about that card's scheduler, not about the gate's arithmetic. Kept +# as a list so the reason travels with the exception. +RACE_FAULTS=" B " + +# Colab free tier dies on workspace LTO. None of these change what is computed. +export CARGO_TERM_COLOR=always +export CARGO_INCREMENTAL=0 +export CARGO_PROFILE_RELEASE_LTO=false +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +export CARGO_PROFILE_RELEASE_OPT_LEVEL=2 +export CARGO_PROFILE_RELEASE_STRIP=false + +LOG_DIR="${ROOT}/scripts/mining-nvidia/colab-results" +mkdir -p "$LOG_DIR" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +LOG="${LOG_DIR}/gate-${STAMP}.log" +SUMMARY="${LOG_DIR}/latest-gate-summary.txt" +TREES="${ROOT}/target/gate-trees" + +exec > >(tee -a "$LOG") 2>&1 + +T0=$(date +%s) +elapsed() { + local s=$(( $(date +%s) - T0 )) + printf '%02d:%02d' $(( s / 60 )) $(( s % 60 )) +} +step() { + echo "" + echo "----------------------------------------------------------------------" + echo "[+$(elapsed)] $*" + echo "----------------------------------------------------------------------" +} + +echo "==============================================" +echo " Hacash CUDA byte-equivalence gate" +echo " time (UTC): ${STAMP}" +echo " log: ${LOG}" +echo "==============================================" + +# --------------------------------------------------------------------------- +# Which code is being gated. Printed before anything else, because a gate run +# whose commit nobody wrote down is a number without a subject. +# --------------------------------------------------------------------------- +COMMIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo 'not-a-git-checkout')" +DIRTY="$(git -C "$ROOT" status --porcelain 2>/dev/null | head -c 1)" +echo "commit: ${COMMIT}$( [[ -n "$DIRTY" ]] && echo ' (working tree DIRTY)' )" + +# The fingerprint keys the resume markers. It has to move when anything that +# could change the verdict moves, and stay still otherwise. +fingerprint() { + { + echo "$COMMIT" + cat "$ROOT"/x16rs/opencl/*.cl \ + "$ROOT"/x16rs-cuda/cuda/*.cu \ + "$ROOT"/x16rs-cuda/cuda/*.cuh \ + "$ROOT"/x16rs-cuda/build.rs \ + "$ROOT"/x16rs-cuda/src/lib.rs \ + "$ROOT"/app/src/x16rs_gate.rs \ + "$ROOT"/src/bin/x16rs_gate.rs 2>/dev/null + echo "shape=${PROD_WG}x256x${PROD_UNIT}x${PROD_BATCHES} headers=${HEADERS}" + } | sha256sum | cut -c1-16 +} +FP="$(fingerprint)" +STATE="${ROOT}/target/gate-state/${FP}" +if [[ "$RESUME" != "1" ]]; then + rm -rf "$STATE" +fi +mkdir -p "$STATE" +echo "state: ${STATE}" +echo " (delete it, or set RESUME=0, to redo steps this fingerprint already proved)" + +fail() { + echo "" + echo "######################################################################" + echo "# RESULT: FAIL" + echo "# $1" + echo "######################################################################" + if [[ -n "${X16RS_CUDA_KERNEL_DIR:-}" ]]; then + echo "" + echo "WARNING: this run stopped while ${GATE} was built from ${X16RS_CUDA_KERNEL_DIR}," + echo " a kernel tree that is WRONG ON PURPOSE. Rebuild before using that binary:" + echo " unset X16RS_CUDA_KERNEL_DIR" + echo " cargo build --release --features cuda --bin x16rs_gate" + fi + { + echo "result=FAIL" + echo "reason=$1" + echo "commit=${COMMIT}" + echo "stamp=${STAMP}" + echo "log=${LOG}" + } > "$SUMMARY" + exit 1 +} + +step "0/3 host, GPU and toolkit" +nvidia-smi || fail "no nvidia-smi: this box has no NVIDIA GPU, or the Colab runtime is not set to GPU (Runtime -> Change runtime type -> T4 GPU)" +nvcc --version || fail "no nvcc: the CUDA toolkit is not on PATH" + +# The gate needs the CUDA kernels compiled in. build.rs only compiles them when +# it finds nvcc, and quietly builds a kernel-less crate when it does not, so +# point it at the toolkit explicitly rather than hope. +if [[ -z "${CUDA_PATH:-}" && -d /usr/local/cuda ]]; then + export CUDA_PATH=/usr/local/cuda +fi +echo "CUDA_PATH=${CUDA_PATH:-}" +nproc 2>/dev/null | sed 's/^/CPU cores (the gate hashes the oracle on these): /' + +GATE="${ROOT}/target/release/x16rs_gate" + +build_gate() { + local label="$1" + echo "" + echo ">>> [+$(elapsed)] building the gate (${label})" + # Heartbeat: free Colab looks dead during a long compile, and a silent cell is + # what makes an operator interrupt a build that was going to finish. + ( while true; do sleep 60; echo " ... [+$(elapsed)] still compiling (${label})"; done ) & + local hb=$! + cargo build --release --features cuda --bin x16rs_gate + local rc=$? + kill "$hb" 2>/dev/null; wait "$hb" 2>/dev/null + echo ">>> [+$(elapsed)] build (${label}) exit=${rc}" + return $rc +} + +# --------------------------------------------------------------------------- +# 1. The real kernels must agree with the CPU. +# --------------------------------------------------------------------------- +step "1/3 equivalence: do the shipping CUDA kernels equal x16rs::block_hash?" +if [[ -f "${STATE}/clean.ok" ]]; then + echo "ALREADY PROVED for this fingerprint at $(cat "${STATE}/clean.ok"); skipping." + echo "(RESUME=0 to redo it.)" +else + unset X16RS_CUDA_KERNEL_DIR + build_gate "shipping kernels" || fail "the CUDA build failed" + + "$GATE" equiv --backend cuda \ + --headers "$HEADERS" \ + --prod-batches "$PROD_BATCHES" \ + --work-groups "$PROD_WG" --local-size 256 --unit-size "$PROD_UNIT" + clean_rc=$? + case $clean_rc in + 0) : ;; + 3) fail "the shipping CUDA kernels do NOT match the CPU. Do not mine with this build and \ +do not report a hashrate from it. The report above names the mismatching nonces, and, when the \ +evidence allows it, the algorithm." ;; + # Exit 4 now means only what it says. It used to also carry the gate's own + # detections: the production count threshold, the best-hash reduction, and a + # bad window dump all reported a broken kernel by returning Err, and every + # Err became exit 4. So a run where the gate had just caught the fault it + # exists to catch printed "nothing was compared" here. Those messages are + # tagged at the source now and exit 3 with the rest of the mismatches. + 4) fail "the gate could not open or run the CUDA device (exit 4). The message above is the \ +CUDA runtime's own. Nothing was compared." ;; + 1) fail "the gate refused to run (exit 1): it was built without the cuda feature, or without \ +kernels because nvcc was not found at build time. Nothing was compared." ;; + *) fail "the gate exited ${clean_rc}, which is not a verdict. Nothing was compared." ;; + esac + echo "PASS: the card's hashes are the CPU's, byte for byte." + date -u +%Y%m%dT%H%M%SZ > "${STATE}/clean.ok" +fi + +if [[ "$SKIP_FAULTS" == "1" ]]; then + step "2/3 SKIPPED (SKIP_FAULTS=1)" + echo "The gate was NOT shown to be able to fail on this box." + echo "The equivalence result above still stands; what is unproven is the gate itself." + { + echo "result=PASS-UNPROVEN" + echo "note=clean run only; the gate was never shown to catch a defect here" + echo "commit=${COMMIT}" + echo "stamp=${STAMP}" + echo "log=${LOG}" + } > "$SUMMARY" + exit 0 +fi + +# --------------------------------------------------------------------------- +# 2. And the gate must catch defects, or step 1 meant nothing. +# +# block_miner.cu includes the algorithm sources from x16rs/opencl, so the three +# trees that proved the OpenCL gate prove this one: A puts shabal's counter off +# by one, B deletes a barrier, C flips one bit of blake's IV. A and C are single +# algorithms and the gate is expected to NAME them; B is a race and the honest +# answer is that no single algorithm is implicated. +# +# A and C are arithmetic: the kernel computes a different number, every time, on +# every device. If either goes uncaught the gate is broken and this run fails. +# +# B is a data race. It was caught on an AMD gfx1201. Whether a given NVIDIA +# scheduler interleaves badly enough to produce a wrong hash in 12288 nonces is +# a property of that card, not of the gate. It has NOT been observed on an +# NVIDIA device by anyone here. So a B that goes uncaught is treated as a +# failure by default (it might really be a gate defect) but ALLOW_RACE_MISS=1 +# downgrades it to a named, recorded weaker result rather than a stop. +# --------------------------------------------------------------------------- +step "2/3 fault injection: can this gate FAIL on kernels that are wrong on purpose?" +python3 scripts/x16rs_gate_trees.py x16rs/opencl "$TREES" faults \ + || fail "could not build the fault trees" + +caught=0 +missed_race="" +for f in A B C; do + echo "" + echo "--- [+$(elapsed)] fault ${f} ---" + if [[ -f "${STATE}/fault-${f}.ok" ]]; then + echo "ALREADY CAUGHT for this fingerprint at $(cat "${STATE}/fault-${f}.ok"); skipping." + caught=$((caught + 1)) + continue + fi + if [[ -f "${STATE}/fault-${f}.race-miss" ]]; then + echo "ALREADY RECORDED as not reproduced on this card at $(cat "${STATE}/fault-${f}.race-miss")." + missed_race="${missed_race}${f}" + continue + fi + export X16RS_CUDA_KERNEL_DIR="${TREES}/faults/${f}" + build_gate "fault ${f}" || fail "the CUDA build failed for fault tree ${f}" + # No production pass here: one exhaustive window already fails thousands of + # nonces, and the point is the verdict, not the volume. + "$GATE" equiv --backend cuda --headers 1 --batches 1 --prod-batches 0 + rc=$? + if [[ $rc -eq 3 ]]; then + echo "OK: fault ${f} was caught (exit 3)." + caught=$((caught + 1)) + date -u +%Y%m%dT%H%M%SZ > "${STATE}/fault-${f}.ok" + elif [[ "$RACE_FAULTS" == *" ${f} "* && "$ALLOW_RACE_MISS" == "1" ]]; then + echo "NOT CAUGHT: fault ${f} is a data race and this card did not reproduce it" + echo " (gate exit ${rc}). ALLOW_RACE_MISS=1, so the run continues with a" + echo " WEAKER result string. Do not quote this run as a clean PASS." + missed_race="${missed_race}${f}" + date -u +%Y%m%dT%H%M%SZ > "${STATE}/fault-${f}.race-miss" + else + extra="" + if [[ "$RACE_FAULTS" == *" ${f} "* ]]; then + extra=" Fault ${f} is a deleted barrier, i.e. a data race, and a race can fail to \ +manifest on a given scheduler. If faults A and C were both caught, the gate's arithmetic is \ +working and this is most likely this card rather than the gate; re-run with ALLOW_RACE_MISS=1 to \ +continue with a result string that records exactly that." + fi + unset X16RS_CUDA_KERNEL_DIR + fail "fault ${f} was NOT caught (gate exit ${rc}, expected 3). The gate cannot detect a \ +broken kernel, so its PASS in step 1 means nothing.${extra}" + fi +done +unset X16RS_CUDA_KERNEL_DIR + +# Leave the operator with a binary that has the REAL kernels in it. A fault-tree +# build sitting at target/release/x16rs_gate is a loaded gun, and after a +# resumed run nobody can tell by looking which tree it came from. Cargo makes +# this a no-op when the last build was already the shipping one. +step "3/3 restoring the shipping kernels in target/release/x16rs_gate" +build_gate "shipping kernels (restore)" || fail "could not rebuild the shipping kernels" + +echo "" +if [[ -n "$missed_race" ]]; then + RESULT="PASS-RACE-NOT-REPRODUCED" +else + RESULT="PASS" +fi +echo "==============================================" +echo " RESULT: ${RESULT} (wall +$(elapsed))" +echo " - the CUDA kernels match x16rs::block_hash byte for byte" +echo " - the gate caught ${caught}/3 deliberately broken kernels" +if [[ -n "$missed_race" ]]; then +echo " - fault(s) ${missed_race} (data race) did NOT reproduce on this card, and were" +echo " waived by ALLOW_RACE_MISS=1. The arithmetic faults were caught; the race was" +echo " not exercised. This is weaker than a clean PASS and is recorded as such." +fi +echo "==============================================" +{ + echo "result=${RESULT}" + echo "faults_caught=${caught}/3" + [[ -n "$missed_race" ]] && echo "race_not_reproduced=${missed_race}" + echo "prod_shape=${PROD_WG}x256x${PROD_UNIT} x ${PROD_BATCHES}" + echo "commit=${COMMIT}" + echo "stamp=${STAMP}" + echo "log=${LOG}" +} > "$SUMMARY" +exit 0 diff --git a/scripts/mining-nvidia/colab_cuda_pool_e2e.md b/scripts/mining-nvidia/colab_cuda_pool_e2e.md index 3d9c2f99..61d07679 100644 --- a/scripts/mining-nvidia/colab_cuda_pool_e2e.md +++ b/scripts/mining-nvidia/colab_cuda_pool_e2e.md @@ -1,39 +1,34 @@ -# CUDA end-to-end on Colab: mainnet node + payout pool + CUDA miner +# CUDA on Colab: prove the kernels, then measure them Pick a GPU runtime first: Runtime -> Change runtime type -> T4 GPU. -What this proves, in order: the CUDA kernels are byte-correct against the CPU -(Cell 2), and the CUDA share list gets the card credited in proportion to the work -it does, measured against a single CPU thread in the same PPLNS window at real -mainnet difficulty (Cells 3 to 7). +The run is **build, prove, measure**, in that order, and it does not reach +"measure" if "prove" fails. -## Why this runs against mainnet, and not a local testnet - -Earlier versions of this document mined a fresh local chain with -`difficulty_adjust_blocks = 8`, on the theory that shrinking the window would let -ASERT pull the difficulty up to something realistic within minutes. That is false, -and it invalidated every run built on it: - -- Off mainnet the ASERT anchor is height `difficulty_adjust_blocks + 2`, and the - target at that height is the fixed constant `ASERT_START_TARGET_NUM = - 0xe9cfffff` (`mint/src/check/difficulty_asert.rs`). `u32_to_hash` gives it - `255 - 0xe9 = 22` leading zero bits. The chain does not climb to that value, it - is pinned there. -- After the anchor, ASERT's half-life is 10800 seconds of WALL CLOCK, not of - block-time budget. Blocks cannot arrive faster than one per second, because - `block_build.rs` sets `nextts = max(now, prev_ts + 1)` and a release node - rejects `blk_time <= prev_blk_time` (`chain/src/verify.rs`). At a 10 second - target that is 1200 blocks, so 20 minutes, per bit of difficulty. -- So the chain sits at 22 leading zero bits for the whole run. With the pool's - lowest legal `share_bits` of 18, the most a share could ever cost there is - `2^4`, sixteen hashes. Every hash is effectively a share, PPLNS credit measures - how fast a worker completes an HTTP round trip, and the proportionality figure - the run exists to produce means nothing. - -The pool now refuses to start in that regime rather than serving it, so a local -testnet cannot be used for this measurement at all. Real difficulty is the only -place the question can be asked, which is also where the AMD gfx1201 baseline in -Cell 7 was taken. +| Cell | What it does | Rough cost on a free T4 | +| --- | --- | --- | +| 0 | Is this runtime actually a GPU runtime? | seconds | +| 1 | Fetch the exact commit, and check it contains the CUDA gate | 1 to 3 min | +| 2 | **The gate.** Every GPU hash equals `x16rs::block_hash`, byte for byte, and the gate is shown to catch kernels that are wrong on purpose | 25 to 45 min | +| 3 | The `x16rs-cuda` test suite, which covers the pool share list itself | 3 to 6 min | +| 4 | Build `fullnode`, `poworker`, `hbit-pool-server` | 5 to 12 min | +| 5 | Configs for the node, the CUDA miner and the CPU rival | seconds | +| 5b | **The tuner.** Measure this card's own launch shape, prove every candidate against the CPU, and check the winner against the shipped preset | 20 to 55 min | +| 6 | Sync the node to the real mainnet tip | 30 min to 2 h | +| 7 | Run the pool, the CUDA miner and the CPU rival, and sample | ~11 min | +| 8 | Raw submission counts | seconds | +| 9 | The verdict: is the card paid for what it mines? | seconds | + +Cells 0 to 3 are the correctness half and need nothing but a GPU. Cells 4 to 9 +are the payment half and need the real chain. If your session is short, run 0 to +3, keep the log, and come back for the rest: they are independent claims. + +The times in that table are estimates, not measurements. The build times are +extrapolated from a 32-thread Windows box where one kernel-tree rebuild of +`x16rs-cuda` plus its dependents took 1 minute 14 seconds; a 2-vCPU Colab VM will +be several times slower, and the gate does four of those rebuilds. The Cell 6 +range is from the one completed mainnet sync on record. Only Cell 7 is fixed by +construction, at `SAMPLE_MINUTES` plus about a minute of setup. Nothing here spends money. The node syncs and validates public blocks, the pool holds a throwaway wallet, and at mainnet difficulty nothing attached to it is @@ -41,70 +36,580 @@ going to win a block. --- -## Cell 1: clone, update, build +## Why the gate comes first + +A hashrate from a kernel nobody checked is not a result. It is a number. + +The equivalence gate compares **every** GPU hash in a window against +`x16rs::block_hash`, the CPU reference, at repeat 1, 4, 8 and 16, across three +launch shapes. It does that by borrowing the pool share list with an all-ones +target, so a whole 1024-nonce window comes off the card at once. On top of that +it runs the production launch shape (589,824 nonces, far more than the share +list holds) and checks the kernel's own hit counter against the CPU's sorted +oracle at 255 rank thresholds, which reads every one of those hashes. + +That gate found real defects three separate times on the AMD card, and it is the +only reason a 50% speed change there was believed. + +The gate also proves it can fail. `scripts/x16rs_gate_trees.py` writes three +copies of the kernel tree with deliberate defects in them: + +| tree | defect | what the gate should say | +| --- | --- | --- | +| A | shabal's counter starts at 2 instead of 1 | `ALGORITHM: shabal (13)` | +| B | the barrier inside the repeat loop is deleted | `NO single algorithm is implicated` (correct: it is a race) | +| C | one bit flipped in blake's IV | `ALGORITHM: blake (0)` | + +`x16rs-cuda/cuda/block_miner.cu` includes the algorithm sources straight out of +`x16rs/opencl`, so the same three trees drive both backends. OpenCL takes a tree +at runtime; CUDA takes it through `X16RS_CUDA_KERNEL_DIR` and a rebuild. + +--- + +## Before you start: the branch has to contain the gate + +**The CUDA half of the gate is newer than the last pushed commit.** At the time +this document was written, `feat/pool-directory-cuda-ptx-panel` on the fork was +at `3248146`, and that commit has: + +- no `CudaBackend` in `app/src/x16rs_gate.rs` (`git show 3248146:app/src/x16rs_gate.rs | grep -c cuda` is `0`), +- no `--backend` flag in `src/bin/x16rs_gate.rs`, +- no `X16RS_CUDA_KERNEL_DIR` in `x16rs-cuda/build.rs`, +- no `colab_cuda_gate.sh` at all. + +Clone that commit and Cell 2 has nothing to run. **Push the branch with the CUDA +gate on it first.** Cell 1 checks for each of those things by name and stops with +the list of what is missing, so you find out in the first two minutes rather than +after a build. + +--- + +## What PASS and FAIL look like + +Every cell that can fail prints a bordered `STOP` block and then raises, so the +notebook halts instead of scrolling on. There is no way to get to Cell 9 past a +failed Cell 2. + +**Cell 2 PASS.** The last lines of the gate are: + +``` +============================================== + RESULT: PASS (wall +31:12) + - the CUDA kernels match x16rs::block_hash byte for byte + - the gate caught 3/3 deliberately broken kernels +============================================== +``` + +and the cell then prints `GATE: PASS`. Inside the run, the equivalence report +that licenses it looks like this (`mismatches : 0`, and no algorithm marked +`NEVER TESTED`). + +The counts below are from the AMD gfx1201 run at these same parameters, not from +a T4. They are quoted because they are determined by the parameters and not by +the card: the corpus, the three exhaustive shapes, the rank thresholds and the +share-list sizes are all fixed, so a T4 at `--headers 4 --prod-batches 1 +--work-groups 48 --unit-size 48` should print the same figures. If yours differ, +the parameters differ. **No part of this report has been observed on an NVIDIA +device by anyone here.** + +``` +================ BYTE-EQUIVALENCE GATE ================ + backend / device : cuda / + hashes compared byte-for-byte : 180499 + exhaustive batches (ENTIRE window dumped and compared) : 48 + production-shape windows : 1 (589824 nonces, all CPU-hashed) + production full-window count checks : 132 (each reads all 589824 GPU hashes) + production best-hash reductions proved minimal : 1 + mismatches : 0 + algorithm coverage (rounds executed, CPU-derived): + 0 blake ... + ... + RESULT: PASS +``` + +**Cell 2 FAIL.** Three different things print differently, and they mean +different things: + +| What you see | What it means | What to do | +| --- | --- | --- | +| `RESULT: FAIL` with `mismatches : N` and a list of nonces | The card's hashes are not the CPU's | Stop. Do not mine, do not quote a hashrate. The report names the algorithm when the evidence allows it | +| `RESULT: FAIL` with `reason=the gate could not open or run the CUDA device (exit 4)` | Nothing was compared | Usually no GPU attached. Cell 0 should have caught it | +| `RESULT: FAIL` with `fault X was NOT caught` | The kernels agreed with the CPU, but the gate could not detect a kernel that is broken on purpose, so that agreement is not evidence | See the note on fault B below | + +**`RESULT: PASS-RACE-NOT-REPRODUCED`.** Fault B is a deleted barrier, which is a +data race. A race is caught because the hardware happened to interleave badly. +It was caught on an AMD gfx1201; **whether an NVIDIA scheduler reproduces it has +never been observed here**, because there is no NVIDIA GPU on the machine this +was written on. If A and C are caught and B is not, the gate's arithmetic is +demonstrably working and the honest reading is that this card did not race. Re-run +with `ALLOW_RACE_MISS=1` to continue; the result string changes to +`PASS-RACE-NOT-REPRODUCED` and stays that way in the summary file, so a run that +used it can never be quoted later as a clean PASS. + +**Cell 9 PASS/FAIL** is a separate claim about payment, not about correctness. +See "Reading the output" at the end. + +--- + +## What in this document has actually been run + +The machine this was written on has an AMD RX 9070 XT and **no NVIDIA GPU**, so +nothing here has been executed against a real CUDA device. Being precise about +that is the point of the gate. + +Verified on the authoring machine (Windows, CUDA 13.3, no NVIDIA device): + +- `cargo build --release --features cuda --bin x16rs_gate` compiles, with + `block_miner.cu` built by a real nvcc for `sm_75` (a T4), `sm_86`, `sm_89` and + a `compute_89` PTX fallback. `nvcc --list-gpu-code` on 13.3 still lists + `sm_75`, so the T4 arch is not one of the ones a modern toolkit dropped. +- Setting `X16RS_CUDA_KERNEL_DIR` to a fault tree forces a real rebuild (1m14s) + rather than reusing the previous object file, and unsetting it rebuilds back. + So the fault-injection mechanism the gate's step 2 depends on works. +- `x16rs_gate equiv --backend cuda` with no NVIDIA device exits **4** with + "the installed NVIDIA driver is older than the CUDA runtime this binary was + built against, or there is no NVIDIA driver at all (code 35)". It does not + crash: the null return from `cudaGetErrorString(35)` that used to segfault is + handled. +- `colab_cuda_gate.sh` on a box with no GPU stops at step 0 with the bordered + FAIL banner and writes `result=FAIL` with a reason to + `latest-gate-summary.txt`. Its build-failure path was exercised too. +- Every Python cell in this document compiles. The Cell 1 content check was run + against both trees: it passes on a tree that has the CUDA gate and, against + commit `3248146`, names five of the six missing pieces. + +**Not run anywhere, and unrunnable here:** + +- Cells 2, 3, 5b and 6 through 9 as a whole. No NVIDIA device. +- Every number Cell 5b prints. The tuner has never measured a CUDA device: its + CUDA path compiles, its device-independent halves (the planner, the corpus, the + launch-fit rules, the NVIDIA occupancy arithmetic) are unit tested on the + authoring machine, and its OpenCL path is the one that has ever run a sweep. + What a T4 does with it is unobserved. +- `x16rs_gate baseline --backend cuda`, which is what the tuned-versus-preset + comparison is measured with. The OpenCL side of that command has produced the + ~2.6% between-process spread the comparison is judged against; the CUDA side + has only ever been compiled. +- The estimates in the SIZE table. They are grid arithmetic on a 40-SM card at + the one hashrate a T4 has been observed at, not a session anyone has timed. +- Cell 5b's logic itself HAS been executed, against fake `poworker` and + `x16rs_gate` binaries replaying canned output, over eight cases: a clean win, a + gain inside the noise, a loss to the preset, a candidate that failed the CPU + oracle, a soak that never settled, a refused session, an "Applied" line whose + file was not patched, and a missing miner config. All eight refused or + installed as intended. That exercises the parsing and the verdicts, and it + proves nothing whatever about the card. +- The equivalence PASS itself on CUDA. The OpenCL half of this gate passes on the + AMD card and the CUDA half shares every line of judgement with it, but "the + CUDA kernels agree with the CPU" is a claim only a T4 run can make. +- Whether an NVIDIA scheduler reproduces fault B. See the note above. +- The Drive parking in Cell 6b. + +--- + +## Cell 0: is this actually a GPU runtime? ```python -!nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader -!set -e; test -d /content/fullnodedev || git clone --depth 1 -b feat/pool-directory-cuda-ptx-panel https://github.com/Moskyera/fullnodedev.git /content/fullnodedev -!set -e; cd /content/fullnodedev && git fetch --depth 1 origin feat/pool-directory-cuda-ptx-panel && git reset --hard FETCH_HEAD && git log --oneline -1 -!test -f "$HOME/.cargo/env" || (curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal) -!set -eo pipefail; cd /content/fullnodedev && . "$HOME/.cargo/env" && export CUDA_PATH=/usr/local/cuda && export PATH=$PATH:/usr/local/cuda/bin && \ - cargo build --release --features cuda --bin fullnode --bin poworker 2>&1 | tail -5 && \ - cargo build --release -p hbit-pool --bin hbit-pool-server 2>&1 | tail -3 +import os, shutil, subprocess + +def die(msg): + """Stop the notebook loudly. A bare `!command` that exits nonzero does NOT + stop a Colab notebook: IPython ignores the status and the next cell runs on + whatever the last successful run left behind. Everything below therefore + raises rather than trusting an exit code nobody reads.""" + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + +if shutil.which("nvidia-smi") is None: + die("""This runtime has no NVIDIA GPU. +Runtime -> Change runtime type -> T4 GPU, then run this cell again. +Every number below would be either an error or a measurement of nothing.""") + +print(subprocess.run(["nvidia-smi", "--query-gpu=name,memory.total,driver_version", + "--format=csv"], capture_output=True, text=True).stdout) + +nvcc = shutil.which("nvcc") or "/usr/local/cuda/bin/nvcc" +if os.path.exists(nvcc): + print(subprocess.run([nvcc, "--version"], capture_output=True, text=True).stdout) +else: + die("""No nvcc. The CUDA toolkit is normally at /usr/local/cuda on Colab. +Without it x16rs-cuda/build.rs compiles a crate with NO kernels in it: every +device call returns NotCompiled, and a gate run would prove nothing. The gate +refuses to start in that state rather than report a pass over zero hashes.""") + +print("CPU cores (the gate hashes its CPU oracle on these):", os.cpu_count()) ``` -Expect `Finished release profile`. The first build takes roughly 7 to 8 minutes. +Two cores is normal on the free tier and is enough. The CPU oracle for one +production window is 589,824 hashes at repeat 16, which is the largest single CPU +cost in the gate. + +--- + +## Cell 1: get exactly the code you mean to test, and check it is the right code + +Two separate failures live here, and the second is the one that has actually bitten. -`set -eo pipefail` is load bearing. Without it a shell pipeline exits with the -status of `tail`, which is always 0, so a compile error scrolls past and the cell -reports success. Cargo leaves the previous binary in place when a build fails, so -the run would then measure whatever was built last time, which may predate the -share-list fix this document exists to test. +`git clone` is skipped when the directory already exists, so a Colab session that +survived a runtime restart silently re-tests an old commit. The fetch and reset +fix that, and the commit is printed so it is in the log. -The `git fetch`/`reset --hard` matters for the same reason: `git clone` is skipped -when the directory already exists, so without it a Colab session that survived a -runtime restart silently re-tests an old commit. +But landing on the right commit is not the same as landing on code that contains +the gate. The CUDA gate is newer than the last push, so the check below looks for +each piece **by name** and says which one is missing. + +```python +import os, subprocess, pathlib + +def die(msg): + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + +REPO = "https://github.com/Moskyera/fullnodedev.git" +BRANCH = "feat/pool-directory-cuda-ptx-panel" +D = "/content/fullnodedev" + +def sh(cmd, cwd=None, what=None): + print("$", cmd) + rc = subprocess.run(cmd, shell=True, cwd=cwd, executable="/bin/bash").returncode + if rc != 0: + die("%s failed (exit %d).\ncommand: %s" % (what or "a shell step", rc, cmd)) + +if not os.path.isdir(D + "/.git"): + sh("git clone --depth 1 -b %s %s %s" % (BRANCH, REPO, D), what="git clone") + +# Force the checkout onto the tip of the branch, whatever it was before. +sh("git fetch --depth 1 origin %s" % BRANCH, cwd=D, what="git fetch") +sh("git reset --hard FETCH_HEAD", cwd=D, what="git reset") + +head = subprocess.run("git log -1 --format='%H%n%h%n%ad%n%s' --date=iso", + shell=True, cwd=D, capture_output=True, text=True).stdout.split("\n") +print() +print("commit :", head[0]) +print("short :", head[1]) +print("date :", head[2]) +print("subject :", head[3]) + +dirty = subprocess.run("git status --porcelain", shell=True, cwd=D, + capture_output=True, text=True).stdout.strip() +print("tree :", "clean" if not dirty else "DIRTY\n" + dirty) + +# Landing on a commit is not the same as landing on the code this notebook runs. +# Each entry is (path, text that must appear in it, why it matters). +REQUIRED = [ + ("scripts/mining-nvidia/colab_cuda_gate.sh", None, + "the gate runner Cell 2 calls"), + ("app/src/x16rs_gate.rs", "CudaBackend", + "the CUDA backend of the gate. Without it there is no --backend cuda, and the " + "gate can only test OpenCL, which this runtime does not have"), + ("src/bin/x16rs_gate.rs", "--backend", + "the flag that selects the backend"), + ("x16rs-cuda/build.rs", "X16RS_CUDA_KERNEL_DIR", + "the knob that rebuilds the CUDA kernels from a deliberately broken tree. " + "Without it the CUDA gate can never be shown to FAIL, and a gate that has only " + "been seen to pass proves nothing"), + ("x16rs/opencl/x16rs.cl", "X16RS_H_BLAKE_INIT", + "blake's initialisation vector, exported so block_miner.cu reads it instead of " + "carrying its own copy. Without this the fault-injection proof is hollow: fault C " + "flips a bit in x16rs.cl, block_miner.cu keeps the old value, the PTX is " + "byte-identical, and the gate returns PASS for a kernel broken on purpose"), + ("scripts/x16rs_gate_trees.py", "faults", + "the three fault trees"), +] + +missing = [] +for path, needle, why in REQUIRED: + p = pathlib.Path(D) / path + if not p.is_file(): + missing.append("%s is not in this commit\n (%s)" % (path, why)) + elif needle and needle not in p.read_text(errors="replace"): + missing.append("%s exists but does not contain %r\n (%s)" % (path, needle, why)) + +if missing: + die("""This commit predates the CUDA gate, so Cell 2 has nothing to run. + +Missing: + """ + "\n ".join(missing) + """ + +Push the branch that carries the CUDA gate and re-run this cell. Measuring a +hashrate off this commit is exactly the mistake the gate exists to prevent.""") + +print("\nall %d required pieces of the CUDA gate are present in this commit" % len(REQUIRED)) + +# Rust, once, for every build cell below. +if not os.path.exists(os.path.expanduser("~/.cargo/env")): + sh("curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal", what="rustup install") + +# ONE profile for the whole notebook. Two reasons, and the second is the +# expensive one: +# +# * the workspace ships lto = "thin" and codegen-units = 1, which on a free +# Colab VM is slow and can be killed for memory; +# * cargo keys its build cache on the profile, so a gate cell that lowers LTO +# and a miner cell that does not would compile the entire dependency tree +# TWICE. colab_cuda_gate.sh exports exactly these values, so matching them +# here is what makes Cell 4 reuse Cell 2's work. +# +# The cost is that absolute hashrates from this run are not comparable with a +# shipping-profile build. The GPU-versus-CPU split in Cell 9 still is, because +# both sides are built the same way, and the GPU kernels are compiled by nvcc at +# -O3 either way. +CARGO_ENV = { + "CARGO_TERM_COLOR": "always", + "CARGO_INCREMENTAL": "0", + "CARGO_PROFILE_RELEASE_LTO": "false", + "CARGO_PROFILE_RELEASE_CODEGEN_UNITS": "16", + "CARGO_PROFILE_RELEASE_OPT_LEVEL": "2", + "CARGO_PROFILE_RELEASE_STRIP": "false", + "CUDA_PATH": "/usr/local/cuda", + "PATH": os.environ["PATH"] + ":/usr/local/cuda/bin:" + os.path.expanduser("~/.cargo/bin"), + "LD_LIBRARY_PATH": "/usr/local/cuda/lib64:" + os.environ.get("LD_LIBRARY_PATH", ""), +} +os.environ.update(CARGO_ENV) +print("build environment pinned; every later build cell inherits it") +``` --- -## Cell 2: correctness before throughput +## Cell 2: the gate. Nothing below this is worth reading until it passes + +This is the long one. It builds the gate, proves the shipping kernels equal the +CPU byte for byte, then rebuilds three more times against kernel trees that are +wrong on purpose and requires the gate to catch each one. + +**It is resumable.** Each step writes a marker under +`target/gate-state//` when it succeeds, and a re-run skips it. The +fingerprint covers the commit, the kernel sources and the gate sources, so +editing any of them starts over by itself. A T4 session that dies during fault B +costs you fault B, not the whole run. `RESUME=0` forces everything to be redone. -A fast miner that computes the wrong hash is worth nothing, so pin the kernels -against the CPU implementation first. +It prints a heartbeat every 60 seconds during a compile, with elapsed time, so a +quiet cell is distinguishable from a dead one. + +If you are short on session and only want the equivalence half, `SKIP_FAULTS=1` +cuts it to roughly a quarter of the time. The result string becomes +`PASS-UNPROVEN`, which is honest: the kernels passed, the gate itself was not +exercised. ```python -!cd /content/fullnodedev && . "$HOME/.cargo/env" && export CUDA_PATH=/usr/local/cuda && export PATH=$PATH:/usr/local/cuda/bin && \ - cargo test -p x16rs-cuda --release --features cuda 2>&1 | tail -30 +import os, subprocess, pathlib + +def die(msg): + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + +D = "/content/fullnodedev" + +GATE_ENV = dict(os.environ) +# GATE_ENV["SKIP_FAULTS"] = "1" # equivalence only, no fault injection +# GATE_ENV["ALLOW_RACE_MISS"] = "1" # see "What PASS and FAIL look like" above +# GATE_ENV["RESUME"] = "0" # redo every step from scratch + +# Streamed line by line rather than captured, so the heartbeat is visible while +# it runs. A captured build looks identical to a hung one for twenty minutes. +proc = subprocess.Popen( + ["bash", "scripts/mining-nvidia/colab_cuda_gate.sh"], + cwd=D, env=GATE_ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1) +for line in proc.stdout: + print(line, end="") +rc = proc.wait() + +summary = pathlib.Path(D) / "scripts/mining-nvidia/colab-results/latest-gate-summary.txt" +if not summary.is_file(): + die("The gate wrote no summary file, so it died before reaching a verdict " + "(exit %d). Its output is above." % rc) + +kv = dict(line.split("=", 1) for line in summary.read_text().splitlines() if "=" in line) +result = kv.get("result", "MISSING") + +# The summary file is overwritten in place, so an aborted run can leave the +# PREVIOUS run's verdict sitting there looking current. Tie it to this commit. +# The fallback matches what the gate script writes when there is no .git, so the +# zip route (see COLAB-T4.md, Option B) compares equal instead of always dying. +head = subprocess.run("git rev-parse --short HEAD", shell=True, cwd=D, + capture_output=True, text=True).stdout.strip() or "not-a-git-checkout" +if kv.get("commit") != head: + die("The gate summary says commit %s, but this checkout is %s, so that file is " + "left over from an earlier run and its verdict is not about this code." + % (kv.get("commit", ""), head)) + +print() +print("=" * 72) +if rc != 0 or result.startswith("FAIL") or result == "MISSING": + die("""GATE: %s (exit %d) +reason: %s + +The CUDA kernels on this card are NOT proven to compute x16rs::block_hash. +Do not run the miner and do not report a hashrate from this build. +Full log: %s""" % (result, rc, kv.get("reason", "see the output above"), kv.get("log", "?"))) + +print("GATE:", result) +print("faults caught:", kv.get("faults_caught", "n/a")) +print("commit:", kv.get("commit"), " log:", kv.get("log")) +if result != "PASS": + print() + print("!" * 72) + print("! This is NOT a clean PASS. It is:", result) + if result == "PASS-UNPROVEN": + print("! The shipping kernels matched the CPU, but SKIP_FAULTS=1 meant the gate") + print("! was never shown to be able to catch a broken kernel on this box.") + if result == "PASS-RACE-NOT-REPRODUCED": + print("! The arithmetic faults were caught. The data-race fault (%s) did not" + % kv.get("race_not_reproduced", "B")) + print("! reproduce on this card and was waived by ALLOW_RACE_MISS=1.") + print("! Quote the result string, not the word PASS.") + print("!" * 72) +print("=" * 72) ``` -`--features cuda` is not optional. Every GPU test is gated behind it, so without -it the run passes with only the one CPU test and proves nothing. If the output -says `1 passed` you forgot the flag. +--- -Two test binaries run, and BOTH matter: +## Cell 3: the `x16rs-cuda` suite, which covers what the gate does not -- `tests/genesis_vector.rs`: 4 tests, including - `cuda_matches_cpu_across_many_inputs` (4096 inputs at repeat 1, 512 at repeat - 16) and `cuda_batch_matches_cpu`. These are the byte-for-byte differential - tests the whole product rests on. -- `src/lib.rs` unit tests, which on a machine with a real device also run - `gpu_share_list_tests::the_share_list_matches_the_cpu_and_leaves_the_best_result_untouched`. - That one is the share-list port itself: a SOLO batch returns exactly the CPU's - single best result with an empty list; the easiest possible target makes the - counter see every nonce in the window while the list stores its capacity and - reports the rest as overflow; a strict target returns exactly the payable nonces - and nothing else; and a pool batch does not leak its counter into the next solo - batch. +The gate proves hash equivalence. It does not exercise the share list's +bookkeeping: overflow when more nonces qualify than the list can hold, the +counter not leaking from a pooled batch into the next solo one, the readback +never reading past the counter. Those are in `x16rs-cuda`. -If that test prints `no usable CUDA device ... skipping`, the runtime has no GPU -attached. Fix the runtime type before going further; nothing below is meaningful. +Two traps make a green run here meaningless, and the cell checks for both. -If any differential test fails, stop. A share list that hands the pool wrong -hashes is worse than no share list. +`--features cuda` is not optional: every GPU test is behind it, and without it +the run passes on the single CPU test. Worse, `gpu_share_list_tests` is +`#[cfg(all(test, cuda_available))]`, and `cuda_available` is set by `build.rs` +only when it actually found nvcc. Without nvcc that module is **not compiled at +all**, so its absence is silent: `cargo test` prints a tidy `ok` for the tests +that remain. The only reliable check is that the test names are present in the +output, so that is what this cell asserts. + +```python +import os, subprocess, pathlib + +def die(msg): + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + +D = "/content/fullnodedev" + +proc = subprocess.Popen( + ["cargo", "test", "-p", "x16rs-cuda", "--release", "--features", "cuda", + "--", "--nocapture"], + cwd=D, env=os.environ, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1) +out = [] +for line in proc.stdout: + print(line, end="") + out.append(line) +rc = proc.wait() +text = "".join(out) + +if rc != 0: + die("cargo test -p x16rs-cuda failed (exit %d). Its output is above." % rc) + +# Each of these must have RUN. Not "not failed": run. +MUST_RUN = [ + ("cuda_matches_cpu_across_many_inputs", + "the differential test: 4096 inputs at repeat 1 and 512 at repeat 16"), + ("cuda_batch_matches_cpu", + "the batch path against the CPU"), + ("cuda_genesis_block_hash_when_available", + "the real mainnet genesis vector"), + ("the_share_list_matches_the_cpu_and_leaves_the_best_result_untouched", + "the share list itself: solo returns the CPU's single best with an empty list; " + "an easy target makes the counter see the whole window while the list stores its " + "capacity and reports the rest as overflow; a strict target returns exactly the " + "payable nonces; and a pool batch does not leak its counter into the next solo batch"), + ("the_blake_iv_is_not_duplicated_into_the_cuda_source", + "the guard that keeps fault C meaningful: if block_miner.cu ever carries its own " + "copy of blake's IV again, patching x16rs.cl stops changing the PTX and the " + "fault-injection proof goes hollow"), +] +absent = [(name, why) for name, why in MUST_RUN if name not in text] +if absent: + die("""These tests did not run, so this green result does not cover them: + + """ + "\n ".join("%s\n (%s)" % (n, w) for n, w in absent) + """ + +The usual cause is that build.rs did not find nvcc, so cfg(cuda_available) is +unset and the GPU test modules were never compiled. Check for the cargo warning +"Using CUDA Toolkit at ..." in the output above; if instead it says "CUDA Toolkit +not found", set CUDA_PATH and re-run Cell 1.""") + +for marker in ("skipping", "no usable CUDA device", "CUDA kernels not compiled"): + if marker in text: + die("""The suite reported %r, which means a GPU test declined to run and still +counted as a pass. Nothing here proves anything about the card.""" % marker) + +print("\nall %d GPU tests ran on the device" % len(MUST_RUN)) +``` --- -## Cell 3: configs +## Cell 4: build the mining binaries + +`fullnode`, `poworker` and `x16rs_gate` are all binaries of the same root +package, so this reuses everything Cell 2 already compiled, provided the profile +matches (Cell 1 pinned it). + +```python +import os, subprocess + +def die(msg): + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + +D = "/content/fullnodedev" + +def build(args, label): + print(">>>", label) + proc = subprocess.Popen(args, cwd=D, env=os.environ, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) + tail = [] + for line in proc.stdout: + print(line, end="") + tail.append(line) + if proc.wait() != 0: + die("%s FAILED.\nCargo leaves the previous binary in place when a build fails, " + "so continuing would measure whatever was built last time." % label) + +build(["cargo", "build", "--release", "--features", "cuda", + "--bin", "fullnode", "--bin", "poworker"], "fullnode + poworker (CUDA)") +build(["cargo", "build", "--release", "-p", "hbit-pool", + "--bin", "hbit-pool-server"], "hbit-pool-server") + +for name in ("fullnode", "poworker", "hbit-pool-server", "x16rs_gate"): + p = os.path.join(D, "target/release", name) + print("%-18s %s" % (name, "%d bytes" % os.path.getsize(p) if os.path.exists(p) else "MISSING")) +``` + +--- + +## Cell 5: configs The node is a plain mainnet node with mining ENABLED. That flag does not make the node hash anything: its only effects are to gate the two miner API routes @@ -115,7 +620,7 @@ is on. The second worker config is the point of the exercise. On the AMD rig the defect was invisible in the miner's own numbers: the card reported a healthy hashrate while a single-threaded CPU miner beside it took the entire PPLNS window. So this -run puts that CPU rival back, on its own address, and Cell 7 measures the split. +run puts that CPU rival back, on its own address, and Cell 9 measures the split. ```python import pathlib, os @@ -213,15 +718,873 @@ print("CPU worker:", CPU_WORKER) --- -## Cell 4: sync the node to the mainnet tip +## Cell 5b: tune this card, and prove the tune was worth having + +### Why this cell exists + +The shape a GPU miner launches (`work_groups` x `local_size` x `unit_size`) is +not a preference, it is worth double-digit percentages, and the optimum is not +the same on two cards: + +- On an RX 9070 XT, `unit_size` 192 beats 64 by about 9%. That kernel is latency + bound and underfed, so more nonces in flight help. +- On a Tesla T4 at repeat 16 the ordering REVERSES: 64 gave 7.54 MH/s, 96 gave + 7.19, 128 gave 7.06, while `nvidia-smi` showed 66 to 67 W against a 70 W cap. + The card is power capped, and a bigger batch cannot buy more work on a card + already at its limit, it only holds it there longer. + +No fixed table serves both, which is the whole reason the tuner has to work on a +card nobody has measured. The NVIDIA rows in `efficiency.rs` are now derived +rather than invented (`nvidia_launch.rs` carries the derivation), but derived is +still not measured, and on YOUR card only a tune is. + +### What this cell runs, and what it will not do + +It runs the real tuner: `app/src/autotune16.rs`, reached the way an operator +reaches it, by putting `[efficiency] benchmark_seconds` above zero in a poworker +config with `[gpu] use_cuda = true`. There is no reimplementation of a sweep, a +score, a proof or a pick anywhere in the cell. The tuner probes the card, plans a +shared corpus from what it measured, proves every candidate against +`x16rs::block_hash` over its entire launch window, sweeps, refines around the +leaders, soaks the winner until temperature, power, clock and hashrate stop +moving, and patches the ini it was given. + +Three things the cell adds on top, none of which the tuner can do for itself: + +1. **An independent check.** After the tune, `x16rs_gate baseline --backend cuda` + measures the chosen shape and the shipped preset on *identical* fixed work + (`--headers 1` and batch counts chosen so both hash exactly the same nonce + range), in two separate processes. A CUDA binary holds one kernel build, so + there is no in-process A/B for it: the bar is the ~2.6% between-process spread + this rig has measured, and a difference under it is reported as no gain. +2. **A refusal.** If any candidate failed the CPU oracle, or the soak never + settled, or the tuner refused the session, nothing is copied into the config + the miner runs. The tuner patches its own config in `/content/tune`; that file + is not what Cell 7 mines with, and this cell only copies out of it on a pass. +3. **A clock.** The estimate is printed before any work starts, the tuner's own + estimate is projected against the timeout the moment it prints, and there is a + hard kill. + +### The two exit-code traps + +`%%bash` swallows exit codes, and so does `!cmd`: IPython ignores the status, so +a failed command does not stop the notebook and the next cell runs on whatever +the last successful run left behind. Everything here goes through `subprocess` +and every exit code is printed on a line of its own. + +The second one is specific to this binary and is worse, because it looks like a +verdict: **poworker exits 0 even when the tune was refused.** +`run_block_mining_benchmark` returns, `poworker()` returns, `main()` returns, +status 0. "[autotune] REJECTED", "the card never settled" and a clean win all +exit 0 alike. So the exit code is necessary and never sufficient, and this cell +parses the report and says which of the two it is reading. + +### Where the time goes, and why the estimate is printed first + +A warmup measured in BATCHES rather than seconds cost this project 37 minutes of +blank screen once. Every wait below is announced before it is spent. + +On a free 2-vCPU Colab VM the dominant cost is not the GPU. `autotune_oracle_threads` +is `available_parallelism() - 2` floored at 1, so the CPU oracle gets ONE thread, +and `prove_shape` CPU-hashes every candidate's whole launch window at repeat 16 +before that candidate's speed is allowed to count. At the 60 kH/s per core that +`x16rs_gate::CPU_ORACLE_HPS_PER_CORE` quotes, that is minutes per candidate. + +`SIZE` picks one number, `[gpu] work_groups`, which is the ceiling of the tuner's +work-group axis. The floor is the card's multiprocessor count (40 on a T4), the +grid is dyadic, and the coarse sweep takes the powers-of-two family of it, so: + +| SIZE | work_groups ceiling | coarse work-group axis | candidates | oracle nonces | estimate on a free T4 | +| --- | --- | --- | --- | --- | --- | +| `fast` | 256 | 64, 128, 256 | 9 | 25.7 M | about 20 min | +| `default` | 512 | 64, 128, 256, 512 | 12 | 55.1 M | about 35 min | +| `full` | 768 | 64, 128, 256, 512, 768 | 15 minus the ones over the batch ceiling | 73.9 M | about 50 min | + +The unit-size axis is 32/64/128 in all three, and 128 is the top because the T4 +measurement says the NVIDIA optimum is at the SMALL end: the grid's job is to +bracket it from both sides. The oracle-nonce column is the sum of those +candidates' launch windows, and it is an upper bound: the latency prune and the +shared corpus only ever remove shapes. The `full` row already has one such +removal in it. Its largest shape, 768x256x128, is a 25.2 M-nonce batch, which at +7.54 MH/s is 3.3 s against a 1.5 s p95 ceiling, so the tuner drops it before +proving it: 99.1 M nonces of grid become 73.9 M. A card that probes slower drops +more, never fewer. + +Those estimates are arithmetic on two constants (7.54 MH/s and 60 kH/s per +oracle core), not measurements of your session, and they assume a 40-SM card. The +tuner prints its own measured estimate within the first minute and the cell +echoes it, projects it through the soak and the final proof, and kills the run +immediately if the projection does not fit `TIMEOUT_MIN`. That is the check worth +watching; the table is only there so nobody stares at a blank cell before it. + +The tune needs no node and no pool: it finishes and returns before poworker ever +contacts `connect`. Run it any time after Cell 4, and after Cell 5 if you want +the result installed into the miner's config automatically. + +### The cell -This is the long cell. It downloads and validates the real chain, which is the -whole reason the measurement below means anything. Leave it running; it reports -progress every 30 seconds and stops on its own. +```python +# =========================================================================== +# Cell 5b: tune this card with the REAL tuner, and prove the tune was worth it +# =========================================================================== +# +# WHAT RUNS. app/src/autotune16.rs, reached the way an operator reaches it: +# [efficiency] benchmark_seconds > 0 in a poworker config with [gpu] use_cuda = +# true. poworker::run_cuda_benchmark builds the TuneRequest, the tuner probes the +# card, plans a shared corpus, proves EVERY candidate against x16rs::block_hash +# over its whole launch window, sweeps, refines, soaks until the card stops +# moving, and patches the ini it was given. Nothing here re-implements a sweep, a +# score, a proof or a pick. What this cell adds is the one thing the tuner cannot +# do for itself: an independent fixed-work measurement of the shape it chose +# against the shape it started from, and a refusal to let a tune that failed its +# own proofs reach the config the miner runs. +# +# TWO EXIT-CODE TRAPS, both already walked into on this project: +# +# * %%bash swallows exit codes, and so does `!cmd`: IPython ignores the status, +# so the next cell runs on whatever the last successful run left behind. +# Every process below runs under subprocess and its exit code is PRINTED on +# its own line and then read. +# * poworker exits 0 EVEN WHEN THE TUNE WAS REFUSED. run_block_mining_benchmark +# returns, poworker() returns, main() returns, status 0. "[autotune] +# REJECTED", "the card never settled" and a clean win all exit 0 alike. The +# exit code here is necessary and never sufficient: the verdict is parsed out +# of the report, and this cell says which of the two it is reading. +# +# THE COST TRAP. A warmup measured in BATCHES rather than seconds cost this +# project 37 minutes of blank screen once. So: the estimate is printed BEFORE any +# work starts, the tuner's own estimate is echoed and projected the moment it +# appears, a heartbeat prints during silence, and a hard timeout kills the run +# rather than letting it eat the session. +# +# WHERE THE TIME GOES, and it is not where you would guess. On a free 2-vCPU +# Colab VM the CPU oracle runs on ONE thread (autotune_oracle_threads is +# available_parallelism() - 2, floored at 1) and it CPU-hashes every candidate's +# entire launch window at repeat 16. That is minutes per candidate and it dwarfs +# the GPU sweep. It is also the thing being bought: a shape whose hashes were +# never proved equal to the CPU's is a number, not a result. + +import math, os, queue, re, shutil, subprocess, sys, threading, time + +# ------------------------------------------------------------------ knobs -- +D = "/content/fullnodedev" +REL = os.path.join(D, "target", "release") +TUNE_DIR = "/content/tune" # the tuner's own config +MINER_CONFIG = os.path.join(REL, "poworker.config.ini") # what Cell 7 mines with +CUDA_DEVICE = 0 +SIZE = "default" # "fast" | "default" | "full", see SIZES below +MODE = "max" # "max" ranks sustained hashrate, "eco" ranks kH/J +PRESET = "nvidia_balanced" # the shipped shape the tune is judged against +TIMEOUT_MIN = 75 # hard kill on the tune. Nothing may outlive the session. +INSTALL = True # copy a PASSING tune into MINER_CONFIG + +# The three sizes differ in ONE thing: [gpu] work_groups, which is the ceiling of +# the tuner's work-group axis (poworker.rs: max_wg = memory_wg.min(work_groups)). +# The floor is the card's multiprocessor count, so on a 40-SM T4 the axis is +# 48..cap on the dyadic grid, the coarse sweep takes the powers-of-two family of +# it, and the unit-size axis is 32/64/128 whatever the cap is. +# +# grid_nonces is the sum of those coarse candidates' launch windows on a 40-SM +# card, which is what the CPU oracle has to hash. It is an UPPER BOUND: the +# latency prune and the shared corpus only ever remove shapes. +SIZES = { + # wg ceiling benchmark_seconds sum of candidate windows + "fast": {"cap": 256, "seconds": 180, "grid_nonces": 25.7e6}, + "default": {"cap": 512, "seconds": 240, "grid_nonces": 55.1e6}, + "full": {"cap": 768, "seconds": 360, "grid_nonces": 73.9e6}, +} + +# Constants the estimate is arithmetic on, each with its source. +T4_MHS = 7.54e6 # measured on a real T4, repeat 16, at 256x256x64 +ORACLE_HPS_CORE = 60_000.0 # x16rs_gate::CPU_ORACLE_HPS_PER_CORE +PROOF_LAUNCHES = 33 # per candidate: 1 all-ones count, 31 rank thresholds, + # 1 best-hash reduction, each reading the whole window +SPREAD_PCT = 2.6 # x16rs_gate::BETWEEN_PROCESS_SPREAD_PCT +BASELINE_RUNS = 7 +BASELINE_WARMUP = 4 # BATCHES, not seconds. Printed in both units below. +BASELINE_TARGET = 25e6 # nonces per baseline run, about 3.3 s on a T4 +BASELINE_BUDGET = 20 * 60 # seconds allowed for both baselines together + + +def die(msg): + """Stop the notebook loudly. A nonzero exit does not stop a Colab notebook, + so everything here raises rather than trusting a status nobody reads.""" + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + sys.stdout.flush() + raise RuntimeError(lines[0]) + + +def hhmm(seconds): + seconds = int(max(0, seconds)) + return "%02d:%02d" % (seconds // 60, seconds % 60) + + +def wrap(text, width=66): + out, line = [], "" + for word in text.split(): + if len(line) + len(word) + 1 > width: + out.append(line) + line = word + else: + line = (line + " " + word).strip() + if line: + out.append(line) + return out + + +# --------------------------------------------------------------- preflight -- +if shutil.which("nvidia-smi") is None: + die("""No nvidia-smi. This runtime has no NVIDIA GPU, so there is no card to +tune and every number below would be a measurement of nothing. +Runtime -> Change runtime type -> T4 GPU.""") + +POWORKER = os.path.join(REL, "poworker") +GATE = os.path.join(REL, "x16rs_gate") +for path, cell in ((POWORKER, "Cell 4"), (GATE, "Cell 2")): + if not os.path.exists(path): + die("%s is missing. Run %s first." % (path, cell)) +if SIZE not in SIZES: + die("SIZE must be one of: %s" % ", ".join(sorted(SIZES))) +size = SIZES[SIZE] + +gpu_name = subprocess.run( + ["nvidia-smi", "--query-gpu=name,power.limit,memory.total", + "--format=csv,noheader"], capture_output=True, text=True).stdout.strip() +cpus = os.cpu_count() or 2 +oracle_threads = max(1, cpus - 2) # autotune_oracle_threads(), poworker.rs +is_t4 = "T4" in gpu_name.upper() + +print("card :", gpu_name) +print("vCPUs : %d, so the tuner's CPU oracle gets %d thread(s)" + % (cpus, oracle_threads)) + +# --------------------------------------------------- what this will cost ---- +# All arithmetic on the two constants above, none of it measured on YOUR card. +# The tuner prints its own estimate within the first minute and that one IS +# measured; this is here so nobody stares at a blank cell until then. +est_oracle = size["grid_nonces"] / (ORACLE_HPS_CORE * oracle_threads) +est_launch = size["grid_nonces"] * PROOF_LAUNCHES / T4_MHS +est_sweep = 0.8 * size["seconds"] # SWEEP_BUDGET_SHARE +est_soak = min(900.0, max(90.0, size["seconds"] / 2.0)) # soak_cap_seconds +est_refine = 0.4 * (est_oracle + est_launch) # up to 8 neighbours +est_final = 300.0 # 255-threshold proof +est_total = est_oracle + est_launch + est_sweep + est_soak + est_refine + est_final + +print("") +print("SIZE = %s: [gpu] work_groups = %d, [efficiency] benchmark_seconds = %d" + % (SIZE, size["cap"], size["seconds"])) +print("estimate, at the 7.54 MH/s measured on a T4 and %d kH/s per oracle core:" + % (ORACLE_HPS_CORE / 1000)) +for label, value in (("CPU oracle, proves every candidate", est_oracle), + ("proof launches on the card", est_launch), + ("timed sweep passes", est_sweep), + ("refinement allowance", est_refine), + ("soak, at most", est_soak), + ("final 255-threshold proof, allowance", est_final)): + print(" %-38s %5.0f s" % (label, value)) +print(" %-38s %5.0f s (about %d min)" + % ("TOTAL, upper bound", est_total, round(est_total / 60))) +print("hard timeout : %d min" % TIMEOUT_MIN) +if not is_t4: + print("NOTE: this is not a T4. That estimate is grid arithmetic for a 40-") + print(" multiprocessor card at 7.54 MH/s and does not transfer. Read the") + print(" tuner's own '[autotune] estimated total' line instead.") +if MODE != "max": + print("NOTE: MODE = %s, so the tuner ranks candidates on something other than" + % MODE) + print(" throughput. The fixed-work check at the end is a HASHRATE") + print(" comparison, so a tuned shape that trades hashrate for watts is") + print(" expected to lose it. Read the kH/J line above it.") +if est_total > TIMEOUT_MIN * 60: + die("""The estimate (%d min) is longer than TIMEOUT_MIN (%d min), so this would +be killed part way and prove nothing. Raise TIMEOUT_MIN, or set SIZE = "fast", +which searches 48..256 work groups instead of 48..%d.""" + % (round(est_total / 60), TIMEOUT_MIN, size["cap"])) +sys.stdout.flush() + + +# ---------------------------------------------------- streaming subprocess -- +def run_streaming(argv, cwd, deadline, label, watch=None): + """Run argv, stamp every line with elapsed time, print a heartbeat while the + child is quiet, kill it at `deadline`. `watch(line)` may return a string, + which kills the child and becomes the abort reason. + + Returns (exit_code, lines, abort_reason); exit_code is None if it was killed. + """ + print("\n>>> %s" % label) + print(">>> " + " ".join(argv)) + sys.stdout.flush() + proc = subprocess.Popen(argv, cwd=cwd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) + lines, abort, q = [], None, queue.Queue() + + def reader(): + for line in proc.stdout: + q.put(line.rstrip("\n")) + q.put(None) + + threading.Thread(target=reader, daemon=True).start() + started = last_seen = time.time() + last_line = "" + while True: + try: + line = q.get(timeout=5) + except queue.Empty: + line = "" + if line is None: + break + if line != "": + lines.append(line) + last_seen, last_line = time.time(), line + print("[+%s] %s" % (hhmm(time.time() - started), line)) + sys.stdout.flush() + if watch is not None: + abort = watch(line) + if abort: + break + elif time.time() - last_seen > 45: + print("[+%s] ... still running, %ds since the last line. The CPU oracle" + " is silent while it hashes. Last line: %s" + % (hhmm(time.time() - started), int(time.time() - last_seen), + last_line[:80])) + sys.stdout.flush() + last_seen = time.time() + if time.time() > deadline: + abort = "the hard timeout" + break + if abort: + print("\n[+%s] KILLING %s: %s" % (hhmm(time.time() - started), label, abort)) + proc.terminate() + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + proc.kill() + print("exit code (%s): killed, no status" % label) + sys.stdout.flush() + return None, lines, abort + rc = proc.wait() + print("[+%s] %s finished" % (hhmm(time.time() - started), label)) + print("exit code (%s): %d" % (label, rc)) + sys.stdout.flush() + return rc, lines, None + + +# --------------------------------- what shape does the SHIPPED preset give? -- +# Read out of the binary rather than copied out of nvidia_launch.rs, so this +# cannot quote a ladder the build does not contain. A config with gpu_profile set +# and NO work_groups / unit_size keys makes resolve_gpu_tuning fall back to the +# preset, and PoWorkConf::new prints what it resolved before anything else runs. +os.makedirs(os.path.join(TUNE_DIR, "preset"), exist_ok=True) +preset_cfg = os.path.join(TUNE_DIR, "preset", "poworker.config.ini") +open(preset_cfg, "w").write("""connect = 127.0.0.1:1 +supervene = 0 -It is safe to re-run: the chain data persists under -`/content/fullnodedev/target/release/hacash_mainnet_data`, so a second run -resumes rather than starting over. +[gpu] +use_opencl = false +use_cuda = false +gpu_profile = %s + +[efficiency] +mode = %s +benchmark_seconds = 0 +""" % (PRESET, MODE)) + +print("\n>>> asking this build what %s resolves to" % PRESET) +sys.stdout.flush() +proc = subprocess.Popen([POWORKER, preset_cfg], cwd=REL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) +killer = threading.Timer(60, proc.kill) # it must not be able to hang the cell +killer.start() +RE_EFF = re.compile( + r"\[efficiency\] mode=(\S+) profile=(\S+) work_groups=(\d+) unit_size=(\d+)") +preset_line = None +try: + for line in proc.stdout: + line = line.rstrip("\n") + print(" ", line) + preset_line = RE_EFF.search(line) + if preset_line: + break +finally: + killer.cancel() + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() +if preset_line is None: + die("""poworker never printed its [efficiency] line, so the shipped preset for +%s could not be read out of this build, and there is nothing to judge a tune +against.""" % PRESET) +preset_shape = (int(preset_line.group(3)), int(preset_line.group(4))) +print("shipped preset : %s = work_groups %d, unit_size %d, local_size 256" + % (PRESET, preset_shape[0], preset_shape[1])) +if preset_shape[0] > size["cap"]: + print("NOTE: the preset's %d work groups is ABOVE this SIZE's %d ceiling, so" + % (preset_shape[0], size["cap"])) + print(" the tuner cannot reach the preset's own shape. The comparison at") + print(" the end is still valid, it just is not a search that contains it.") +sys.stdout.flush() + + +# ------------------------------------------------------- the tuner's config -- +# Every key the tune writes back MUST already be present: apply_benchmark_pick +# REPLACES keys, it does not add them, so a missing unit_size line would mean a +# tune that silently keeps the old value. gpu_profile, work_groups, unit_size and +# benchmark_seconds are all here for that reason. +# +# supervene = 0 means no CPU assist threads, so Economics::cpu_watts is 0 and the +# watts in the report are the card's, straight from nvidia-smi. +os.makedirs(TUNE_DIR, exist_ok=True) +tune_cfg = os.path.join(TUNE_DIR, "poworker.config.ini") +open(tune_cfg, "w").write("""connect = 127.0.0.1:18082 +supervene = 0 +nonce_max = 4294967295 +notice_wait = 3 + +[gpu] +use_opencl = false +use_cuda = true +cuda_device = %d +gpu_profile = %s +work_groups = %d +local_size = 256 +unit_size = 64 + +[efficiency] +mode = %s +benchmark_seconds = %d +dynamic_supervene = false +oom_fallback = true +max_temp_c = 0 +pause_if_unprofitable = false +power_cost_kwh = 0 +hac_price = 0 +stats_file = tune-stats.json +""" % (CUDA_DEVICE, PRESET, size["cap"], MODE, size["seconds"])) +print("\ntune config :", tune_cfg) +print("miner config : %s %s" % (MINER_CONFIG, + "" if os.path.exists(MINER_CONFIG) else "(MISSING: Cell 5 writes it)")) +print("no node needed : the tune finishes and returns before poworker ever") +print(" contacts `connect`.") +sys.stdout.flush() + + +# ------------------------------------------------------------- run the tune -- +RE_ESTIMATE = re.compile(r"estimated total before the soak: about (\d+)s") +started_at = time.time() +deadline = started_at + TIMEOUT_MIN * 60 + + +def watch(line): + """Early abort. The tuner's own estimate covers the timed sweep passes and + the CPU oracle. It does NOT cover the 33 proof launches per candidate, the + refinement, the soak or the 255-threshold final proof, so this projects it by + 1.5 and adds the soak cap and the final-proof allowance. Better to stop in + the first minute than to be killed 60 minutes in with nothing to show.""" + m = RE_ESTIMATE.search(line) + if not m: + return None + tuner_est = float(m.group(1)) + need = tuner_est * 1.5 + est_soak + est_final + left = deadline - time.time() + print(" >>> the tuner's OWN estimate is %ds. Projected through the final" + " proof: %d min. Left before the hard timeout: %d min." + % (tuner_est, round(need / 60), round(left / 60))) + sys.stdout.flush() + if need > left: + return ("this tune projects to %d more minutes and only %d remain. Nothing" + " has been wasted yet: set SIZE = \"fast\", or raise TIMEOUT_MIN." + % (round(need / 60), round(left / 60))) + return None + + +rc, log, abort = run_streaming( + [POWORKER, tune_cfg], REL, deadline, + "the tuner (poworker, benchmark_seconds=%d)" % size["seconds"], watch=watch) +text = "\n".join(log) + +# --------------------------------------------------------------- read it ---- +UNITS = {"H/s": 1.0, "kH/s": 1e3, "MH/s": 1e6} +chosen = re.search( + r"chosen shape\s*:\s*work_groups=(\d+) local_size=(\d+) unit_size=(\d+)", text) +sust = re.search( + r"sustained\s*:\s*([\d.]+) (MH/s|kH/s|H/s) raw, ([\d.]+) (MH/s|kH/s|H/s)", text) +lat = re.search(r"batch latency\s*:\s*p50 (\d+) ms, p95 (\d+) ms", text) +power = re.search(r"board power\s*:\s*([\d.]+) W (measured|estimated)", text) +soak = re.search( + r"soak\s*:\s*(\d+) passes over (\d+)s, (settled|DID NOT SETTLE[^\n]*)", text) +applied = re.search( + r"\[benchmark\] Applied gpu_profile=(\S+) \(work_groups=(\d+), unit_size=(\d+)\)", + text) +rejects = [l for l in log if "REJECTED" in l and "[autotune] REJECTED:" not in l] +proof_bad = [l for l in rejects if "failed the equivalence proof" in l] +refused = [l for l in log if "[autotune] REJECTED:" in l] + +fail = [] +if abort: + fail.append("the run was killed: %s." % abort) +elif rc != 0: + fail.append("poworker exited %s. It exits 0 even when a tune is refused, so a" + " nonzero status is something else: a panic, or the VM's OOM" + " killer." % rc) +if refused: + fail.append("the tuner refused the whole session. %s" % refused[0]) +if proof_bad: + fail.append("%d candidate(s) FAILED THE CPU ORACLE, or errored inside the" + " proof that runs it. A shape whose hashes were not proved equal" + " to x16rs::block_hash must not reach a mining config, so nothing" + " was installed. The first one: %s" % (len(proof_bad), proof_bad[0])) +elif rejects: + fail.append("%d candidate(s) were rejected for reasons other than the proof." + " A healthy tune rejects none, so this cell will not install a" + " config over it." % len(rejects)) +if chosen is None or sust is None: + fail.append("no report block was printed, so no shape was chosen.") +if soak is not None and not soak.group(3).startswith("settled"): + fail.append("the soak did not settle, so the shape is not proven to sustain:" + " %s" % soak.group(3)) +if applied is None and not fail: + fail.append("the tuner never printed '[benchmark] Applied ...', so it reported" + " a winner and did not patch its own config.") + +print("\n" + "=" * 72) +print(" THE TUNE") +print("=" * 72) +for line in rejects + refused: + print(" rejection:", line) +shape = raw = valid = None +if chosen and sust: + shape = (int(chosen.group(1)), int(chosen.group(3))) + raw = float(sust.group(1)) * UNITS[sust.group(2)] + valid = float(sust.group(3)) * UNITS[sust.group(4)] + print(" chosen shape : work_groups=%d local_size=256 unit_size=%d" + % (shape[0], shape[1])) + print(" hashrate : %.2f MH/s raw, %.2f MH/s after the stale work a" + " template change throws away" % (raw / 1e6, valid / 1e6)) + if lat: + print(" batch latency : p50 %s ms, p95 %s ms, against the 1500 ms ceiling" + % (lat.group(1), lat.group(2))) + if power and power.group(2) == "measured": + watts = float(power.group(1)) + print(" board power : %.0f W, measured by nvidia-smi" % watts) + print(" efficiency : %.1f kH/J raw, %.1f kH/J after stale work." + % (raw / watts / 1e3, valid / watts / 1e3)) + print(" Card only: supervene = 0 here, so the tuner's") + print(" cpu_watts term is 0 and this is the whole draw") + print(" it scored on.") + else: + print(" board power : NOT MEASURED, so there is no kH/J. On NVIDIA") + print(" that means nvidia-smi did not report power.draw,") + print(" and an eco tune would have ranked every shape on") + print(" one constant, which is max mode by another name.") + if soak: + print(" soak : %s passes over %ss, %s" + % (soak.group(1), soak.group(2), soak.group(3))) +else: + print(" no report. The last lines the tuner printed were:") + for line in log[-15:]: + print(" " + line) +sys.stdout.flush() + + +# ------------------- an independent fixed-work check: tuned vs the preset ---- +# Two `x16rs_gate baseline` runs: the same kernel, the same height (repeat 16) and +# the same fixed corpus the tuner used, measured by a DIFFERENT binary in a +# different process, so the tune does not mark its own homework. +# +# Identical work on both sides on purpose: --headers 1 pins both shapes to one +# intro, and the batch counts are chosen so both hash exactly the same nonce +# range. What is left is the between-process spread, about 2.6% on this kernel, +# and that is the bar a claimed gain has to clear. +compare = None +if shape and not fail: + if shape == preset_shape: + print("\nThe tuner chose the shipped preset's own shape, so there is nothing") + print("to compare: the baseline would be the same shape twice.") + else: + per_w = shape[0] * 256 * shape[1] + per_p = preset_shape[0] * 256 * preset_shape[1] + block = (per_w * per_p) // math.gcd(per_w, per_p) # identical work needs + total = block * max(1, math.ceil(BASELINE_TARGET / block)) # a common multiple + rate = raw if raw and raw > 0 else T4_MHS + runs = BASELINE_RUNS + while runs > 3 and 2 * runs * total / rate > BASELINE_BUDGET / 2: + runs -= 1 + print("\nfixed-work check: %d runs of %d nonces on EACH shape, the same" + % (runs, total)) + print("nonces and the same single header on both sides, about %.1f s a run" + % (total / rate)) + print("at the tuned shape's own %.2f MH/s." % (rate / 1e6)) + print("warmup is %d BATCHES, once, before any timed run: about %.1f s for" + % (BASELINE_WARMUP, BASELINE_WARMUP * per_w / rate)) + print("the tuned shape and %.1f s for the preset. Both sides together:" + % (BASELINE_WARMUP * per_p / rate)) + print("about %d min." % max(1, round(2 * runs * total / rate / 60 + 1))) + sys.stdout.flush() + bl_deadline = time.time() + BASELINE_BUDGET + + def baseline(what, wg, us): + rc2, out, ab = run_streaming( + [GATE, "baseline", "--backend", "cuda", + "--cuda-device", str(CUDA_DEVICE), + "--work-groups", str(wg), "--local-size", "256", + "--unit-size", str(us), "--headers", "1", + "--batches", str(total // (wg * 256 * us)), "--runs", str(runs), + "--warmup", str(BASELINE_WARMUP)], + REL, bl_deadline, "baseline %s (%dx256x%d)" % (what, wg, us)) + if ab or rc2 != 0: + return None, None + body = "\n".join(out) + med = re.search(r"median\s*:\s*([\d.]+) (MH/s|kH/s|H/s)", body) + spr = re.search(r"peak-to-peak ([\d.]+)%", body) + if med is None: + return None, None + return (float(med.group(1)) * UNITS[med.group(2)], + float(spr.group(1)) if spr else None) + + tuned_hps, tuned_spread = baseline("tuned", shape[0], shape[1]) + preset_hps, preset_spread = baseline("preset", preset_shape[0], preset_shape[1]) + if tuned_hps is None or preset_hps is None: + fail.append("the fixed-work baseline did not complete, so the tuned" + " shape was never compared with the shipped preset by" + " anything except the tuner itself.") + else: + delta = (tuned_hps - preset_hps) / preset_hps * 100.0 + compare = (tuned_hps, preset_hps, delta) + print("\n" + "=" * 72) + print(" TUNED vs SHIPPED PRESET: fixed work, separate processes") + print("=" * 72) + print(" tuned %5dx256x%-4d: %6.2f MH/s (its own runs spanned %s)" + % (shape[0], shape[1], tuned_hps / 1e6, + "%.2f%%" % tuned_spread if tuned_spread is not None else "?")) + print(" preset %5dx256x%-4d: %6.2f MH/s (its own runs spanned %s)" + % (preset_shape[0], preset_shape[1], preset_hps / 1e6, + "%.2f%%" % preset_spread if preset_spread is not None else "?")) + print(" difference : %+.2f%%, against the %.1f%% between-" + "process spread" % (delta, SPREAD_PCT)) + if abs(delta) < SPREAD_PCT: + print(" VERDICT : NO GAIN SHOWN. These two shapes measure") + print(" the same here. The tune is still worth") + print(" having, it PROVED the shape against the") + print(" CPU, but do not quote a speedup.") + elif delta > 0: + print(" VERDICT : the tuned shape BEATS the shipped preset") + print(" by %.2f%%, which clears the spread." % delta) + else: + print(" VERDICT : the tuned shape LOST to the preset by") + print(" %.2f%%, outside the spread. That is a" + % -delta) + print(" contradiction worth reporting, and no") + print(" config is installed over it.") + fail.append("the tuned shape measured %.2f%% SLOWER than the shipped" + " preset in an independent fixed-work run." % -delta) +sys.stdout.flush() + + +# ------------------------------------------------- install, or say why not --- +def patch_ini(path, wg, us, profile): + """Rewrite [gpu] work_groups / unit_size / gpu_profile, ADDING the keys when + they are absent. efficiency.rs apply_benchmark_pick only replaces keys that + already exist, which is right for the tuner's own config (written above with + all of them) and not enough for a config written by Cell 5.""" + want = {"gpu_profile": str(profile), "work_groups": str(wg), "unit_size": str(us)} + out, in_gpu, seen, gpu_at = [], False, set(), None + for line in open(path).read().splitlines(): + t = line.strip() + if t.startswith("["): + in_gpu = t.lower() == "[gpu]" + if in_gpu: + gpu_at = len(out) + 1 # the line just after the header + elif in_gpu and "=" in t and not t.startswith(("#", ";")): + key = t.split("=", 1)[0].strip() + if key in want: + seen.add(key) + line = "%s = %s" % (key, want[key]) + out.append(line) + missing = ["%s = %s" % (k, v) for k, v in want.items() if k not in seen] + if gpu_at is None: + out += ["", "[gpu]"] + missing + else: + out[gpu_at:gpu_at] = missing + open(path, "w").write("\n".join(out) + "\n") + + +print("\n" + "#" * 72) +if fail: + print("# RESULT: FAIL. Nothing was written to the miner's config.") + for reason in fail: + print("#") + for line in wrap(reason): + print("# " + line) + print("#") + for line in wrap("The tuner may still have patched its OWN config at %s. That" + " file is not what Cell 7 mines with, and this cell copied" + " nothing out of it." % tune_cfg): + print("# " + line) + print("#" * 72) + print("total wall time : %s" % hhmm(time.time() - started_at)) + raise RuntimeError(fail[0]) + +print("# RESULT: PASS") +print("# shape %dx256x%d, proved against the CPU over its whole %d-nonce window," + % (shape[0], shape[1], shape[0] * 256 * shape[1])) +print("# settled under soak, and %s" + % ("measured %+.2f%% against the shipped preset." % compare[2] if compare + else "identical to the shipped preset.")) +print("#" * 72) +print("the tuner patched its own config: %s -> gpu_profile=%s work_groups=%s" + " unit_size=%s" % (tune_cfg, applied.group(1), applied.group(2), + applied.group(3))) +if (int(applied.group(2)), int(applied.group(3))) != shape: + die("""The shape in the report and the shape written to the ini disagree. +The report says %dx%d, the ini was given %sx%s. Do not mine on either until that +is understood.""" % (shape[0], shape[1], applied.group(2), applied.group(3))) +# And read it back off the disk, because "Applied" is a log line and the file is +# the thing. apply_benchmark_pick REPLACES keys and never adds them, so a config +# missing a key would print exactly this line and change nothing. +on_disk = dict(re.findall(r"^\s*(work_groups|unit_size)\s*=\s*(\d+)\s*$", + open(tune_cfg).read(), re.M)) +if (int(on_disk.get("work_groups", -1)), int(on_disk.get("unit_size", -1))) != shape: + die("""The tuner said it applied %dx%d but %s holds work_groups=%s +unit_size=%s. Nothing was installed.""" + % (shape[0], shape[1], tune_cfg, on_disk.get("work_groups"), + on_disk.get("unit_size"))) +if INSTALL and os.path.exists(MINER_CONFIG): + patch_ini(MINER_CONFIG, shape[0], shape[1], applied.group(1)) + print("installed into %s:" % MINER_CONFIG) + for line in open(MINER_CONFIG).read().splitlines(): + if line.strip().startswith(("work_groups", "unit_size", "gpu_profile")): + print(" " + line) +elif INSTALL: + print("MINER_CONFIG does not exist yet (Cell 5 writes it). Run this cell again") + print("after Cell 5, or set [gpu] work_groups = %d and unit_size = %d there by" + % (shape[0], shape[1])) + print("hand.") +print("total wall time : %s" % hhmm(time.time() - started_at)) +``` + +### What PASS and FAIL look like + +The blocks below are what the cell's own printing produces given the tuner's +format strings. The rates in them are the ones measured on a real T4 at +256x256x64; **the cell itself has never been executed against an NVIDIA device by +anyone here**, so read them as the shape of the output, not as a prediction of +your card's numbers. + +**PASS.** Four things have to be true together: no candidate was rejected, the +soak settled, the ini on disk really holds the chosen shape, and the independent +fixed-work run did not contradict the tune. + +``` +======================================================================== + THE TUNE +======================================================================== + chosen shape : work_groups=256 local_size=256 unit_size=64 + hashrate : 7.54 MH/s raw, 7.40 MH/s after the stale work a template change throws away + batch latency : p50 552 ms, p95 571 ms, against the 1500 ms ceiling + board power : 66 W, measured by nvidia-smi + efficiency : 114.2 kH/J raw, 112.1 kH/J after stale work. + soak : 6 passes over 148s, settled + +======================================================================== + TUNED vs SHIPPED PRESET: fixed work, separate processes +======================================================================== + tuned 256x256x64 : 7.54 MH/s (its own runs spanned 0.93%) + preset 320x256x64 : 7.10 MH/s (its own runs spanned 0.93%) + difference : +6.20%, against the 2.6% between-process spread + VERDICT : the tuned shape BEATS the shipped preset + by 6.20%, which clears the spread. + +######################################################################## +# RESULT: PASS +######################################################################## +``` + +**PASS with no gain.** This is a result, not a disappointment, and the cell says +so in as many words. The preset ladder was derived from the same occupancy +arithmetic the tuner searches around, so the tuner landing on something that +measures the same is the expected outcome on a T4. What was bought is the proof: +this shape's hashes were compared against `x16rs::block_hash` over its whole +window, which no preset has ever been. + +``` + difference : +0.94%, against the 2.6% between-process spread + VERDICT : NO GAIN SHOWN. These two shapes measure + the same here. The tune is still worth + having, it PROVED the shape against the + CPU, but do not quote a speedup. +``` + +**FAIL.** The cell prints a bordered `RESULT: FAIL` block naming every reason and +then raises, so the notebook stops. Nothing is written to the miner's config in +any of these. + +| What you see | What it means | What to do | +| --- | --- | --- | +| `N candidate(s) FAILED THE CPU ORACLE` with the `[autotune] WxU: REJECTED (failed the equivalence proof: ...)` line quoted | A candidate's hashes did not equal `x16rs::block_hash`, or the proof errored on the device. On a card whose kernels passed Cell 2, the first reading is a shape-dependent defect: how nonces are placed or how the per-work-group reduction is built | Stop. This is Cell 2 territory: rerun the gate, and quote the failing shape. Do not mine on this build | +| `N candidate(s) were rejected for reasons other than the proof` | Out of memory, a launch the device refused, a shape the corpus could not tile | Read the quoted line. A healthy tune rejects none, which is why this also refuses to install | +| `the soak did not settle` | The winner's hashrate, temperature, power or clock were still moving after the soak cap, so the shape is not proven to sustain | Raise `benchmark_seconds` (the soak cap is half of it, up to 900 s). On Colab, also suspect a shared host | +| `the tuner refused the whole session` (`[autotune] REJECTED: ...`) | Planning failed before anything was measured. Usually "only 1 launch shape survived planning": a tune of one shape is a report, not a comparison | The message names the fix, and it is normally a lower `SIZE` or a larger `benchmark_seconds` | +| `the tuned shape measured X% SLOWER than the shipped preset` | The tune and an independent fixed-work run disagree by more than the between-process spread | Worth reporting. Keep both logs: this is either a real regression in the pick or a real hole in the comparison | +| `poworker exited N` | Not a refused tune, which exits 0. A panic, or the VM's OOM killer taking the process | Check the tail of the log. The oracle peaks at 64 bytes a nonce, so a 16.8 M-nonce window wants about 1.1 GB | +| `the run was killed: the hard timeout` | The tune outlived `TIMEOUT_MIN` | Lower `SIZE` or raise `TIMEOUT_MIN`. If it was killed in the first minute instead, the cell projected the tuner's own estimate past the timeout and stopped before spending it | + +**After a PASS**, `target/release/poworker.config.ini` holds the tuned shape and +Cell 7 mines with it. The tune's own log is beside its config, at +`/content/tune/poworker.log`, and it holds every candidate's rate, watts, +temperature and proof line: keep it, because the report block alone does not +carry the losers. + + +--- + +## Cell 6: sync the node to the mainnet tip + +### Why this runs against mainnet, and not a local testnet + +Earlier versions of this document mined a fresh local chain with +`difficulty_adjust_blocks = 8`, on the theory that shrinking the window would let +ASERT pull the difficulty up to something realistic within minutes. That is false, +and it invalidated every run built on it: + +- Off mainnet the ASERT anchor is height `difficulty_adjust_blocks + 2`, and the + target at that height is the fixed constant `ASERT_START_TARGET_NUM = + 0xe9cfffff` (`mint/src/check/difficulty_asert.rs`). `u32_to_hash` gives it + `255 - 0xe9 = 22` leading zero bits. The chain does not climb to that value, it + is pinned there. +- After the anchor, ASERT's half-life is 10800 seconds of WALL CLOCK, not of + block-time budget. Blocks cannot arrive faster than one per second, because + `block_build.rs` sets `nextts = max(now, prev_ts + 1)` and a release node + rejects `blk_time <= prev_blk_time` (`chain/src/verify.rs`). At a 10 second + target that is 1200 blocks, so 20 minutes, per bit of difficulty. +- So the chain sits at 22 leading zero bits for the whole run. With the pool's + lowest legal `share_bits` of 18, the most a share could ever cost there is + `2^4`, sixteen hashes. Every hash is effectively a share, PPLNS credit measures + how fast a worker completes an HTTP round trip, and the proportionality figure + the run exists to produce means nothing. + +The pool now refuses to start in that regime rather than serving it, so a local +testnet cannot be used for this measurement at all. Real difficulty is the only +place the question can be asked, which is also where the AMD gfx1201 baseline in +Cell 9 was taken. + +### The cell + +This is the longest wait in the notebook. It downloads and validates the real +chain. Leave it running; it reports progress every 30 seconds and stops on its +own. + +It resumes rather than starting over **as long as the VM lives**: the chain data +sits in `/content/fullnodedev/target/release/hacash_mainnet_data`, so re-running +this cell after an interrupt picks up where it stopped. It does NOT survive the +VM being recycled, because `/content` goes with it. Cell 6b parks a completed +sync on Drive if you expect to come back. Expect about 2.7 GB of chain data, measured from a completed sync of the same chain, which is comfortably inside Colab's disk. The time is dominated by @@ -229,6 +1592,16 @@ download and validation rather than by anything on the GPU. ```python import subprocess, os, time, json, urllib.request + +def die(msg): + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + D = "/content/fullnodedev/target/release" env = dict(os.environ, LD_LIBRARY_PATH="/usr/local/cuda/lib64:" + os.environ.get("LD_LIBRARY_PATH","")) @@ -268,6 +1641,16 @@ def lzbits(hexstr): n += 4 return n +def datadir_mb(): + total = 0 + for root, _, files in os.walk(os.path.join(D, "hacash_mainnet_data")): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + return total / (1024.0 * 1024.0) + node = subprocess.Popen(["./fullnode"], cwd=D, stdout=open("/content/node.log","w"), stderr=subprocess.STDOUT, env=env, start_new_session=True) up = False @@ -281,7 +1664,7 @@ for _ in range(90): if not up: print(open("/content/node.log").read()[-2000:]) node.terminate() - raise SystemExit("the node never answered on 18080; its log is above") + die("The node never answered on 18080. Its log is above.") # Two conditions, both required. # @@ -301,21 +1684,27 @@ if not up: # high, which is the worse way to be wrong. NEED_BITS = 34 prev_h, synced = None, False +t0 = time.time() print("syncing the real chain. This is the slow part; leave it running.") +print("re-running this cell after an interrupt RESUMES; it does not start over.") for i in range(240): # 240 x 30s = up to two hours time.sleep(30) + if node.poll() is not None: + print(open("/content/node.log").read()[-2000:]) + die("The node process exited during sync. Its log is above.") try: h = get("http://127.0.0.1:18080/query/latest")["height"] t = get("http://127.0.0.1:18080/query/miner/pending").get("target_hash") except Exception as e: print(" poll failed (%s); the node is busy, continuing" % e) continue + mins = (time.time() - t0) / 60.0 if t is None: - print(" height %-8d (no template yet)" % h) + print(" t+%5.1fmin height %-8d (no template yet)" % (mins, h)) continue n = lzbits(t) rate = "" if prev_h is None else " +%d blocks/30s" % (h - prev_h) - print(" height %-8d work 2^%-3d%s" % (h, n, rate)) + print(" t+%5.1fmin height %-8d work 2^%-3d %6.0f MB%s" % (mins, h, n, datadir_mb(), rate)) if n >= NEED_BITS and prev_h is not None and (h - prev_h) < 5: synced = True break @@ -323,22 +1712,64 @@ for i in range(240): # 240 x 30s = up to two hours if not synced: print(open("/content/node.log").read()[-1500:]) node.terminate() - raise SystemExit("the node did not reach a stable tip at 2^%d work within two hours. " - "Its log is above; check connectivity to the boot nodes." % NEED_BITS) + die("""The node did not reach a stable tip at 2^%d work within two hours. +Its log is above; check connectivity to the boot nodes. +Re-running this cell resumes from the height it reached.""" % NEED_BITS) tip = get("http://127.0.0.1:18080/query/latest")["height"] work = lzbits(get("http://127.0.0.1:18080/query/miner/pending")["target_hash"]) json.dump({"tip": tip, "work_bits": work}, open("/content/sync.json","w")) print() print("SYNCED. tip height %d, a block costs 2^%d hashes." % (tip, work)) -print("Leave this node running and go straight to Cell 5.") +print("Leave this node running and go straight to Cell 7.") +``` + +--- + +## Cell 6b (optional): park the synced chain on Drive + +Only worth it if you expect the VM to be recycled before you finish. Restoring +2.7 GB from Drive and syncing the delta beats validating 700k blocks again, but +it is not free either, so this is opt-in. + +Run the archive half **after** Cell 6 reports SYNCED, and the restore half +**before** Cell 6 on a fresh VM. + +```python +# --- restore (run BEFORE Cell 6 on a fresh VM) --- +import os, subprocess +from google.colab import drive +drive.mount("/content/drive") +ARCHIVE = "/content/drive/MyDrive/hacash-colab/hacash_mainnet_data.tar" +DEST = "/content/fullnodedev/target/release" +if os.path.exists(ARCHIVE) and not os.path.exists(DEST + "/hacash_mainnet_data"): + os.makedirs(DEST, exist_ok=True) + print("restoring %.1f GB from Drive; this takes a while but beats a full resync" + % (os.path.getsize(ARCHIVE) / 1e9)) + subprocess.run(["tar", "-xf", ARCHIVE, "-C", DEST], check=True) + print("restored. Cell 6 will sync only the delta since the archive was made.") +else: + print("nothing to restore" if not os.path.exists(ARCHIVE) else "chain data already present") +``` + +```python +# --- archive (run AFTER Cell 6 says SYNCED, with the node STOPPED) --- +# The node must not be writing while tar reads, or the archive is torn. +import os, subprocess +from google.colab import drive +drive.mount("/content/drive") +subprocess.run("pkill -9 -x fullnode; sleep 3", shell=True) +os.makedirs("/content/drive/MyDrive/hacash-colab", exist_ok=True) +subprocess.run(["tar", "-cf", "/content/drive/MyDrive/hacash-colab/hacash_mainnet_data.tar", + "-C", "/content/fullnodedev/target/release", "hacash_mainnet_data"], check=True) +print("archived. Re-run Cell 6 to bring the node back up before Cell 7.") ``` --- -## Cell 5: run the pool, the CUDA miner and the CPU rival +## Cell 7: run the pool, the CUDA miner and the CPU rival -The node from Cell 4 must still be running. This cell does not start one. +The node from Cell 6 must still be running. This cell does not start one. `share_bits` is computed, not typed. The pool serves a share target eased from the network target by `share_bits`, so what a share COSTS is @@ -349,6 +1780,16 @@ per second for a single CPU thread, which is exactly the spread being measured. ```python import subprocess, os, time, json, urllib.request, re + +def die(msg): + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + raise RuntimeError(lines[0]) + D = "/content/fullnodedev/target/release" GPU_WORKER = "1AVRuFXNFi3rdMrPH4hdqSgFrEBnWisWaS" CPU_WORKER = "1AhGNNrHUNaiwS2GWBPR4UuDXjEiDwoE3v" @@ -365,7 +1806,7 @@ def get(url, t=15, tries=3): time.sleep(2) raise last -# Delete last run's evidence BEFORE producing this run's. Cells 6 and 7 read these +# Delete last run's evidence BEFORE producing this run's. Cells 8 and 9 read these # files unconditionally, so an abort anywhere below would otherwise leave them # reading the previous attempt byte for byte, and reprinting its verdict as if it # belonged to this run. @@ -381,8 +1822,8 @@ SHARE_BITS = min(40, max(18, WORK_BITS - SHARE_COST_BITS)) print("run %s: a block costs 2^%d, share_bits=%d, so a share costs about 2^%d hashes." % (RUN_ID, WORK_BITS, SHARE_BITS, WORK_BITS - SHARE_BITS)) if WORK_BITS - SHARE_BITS < 16: - raise SystemExit("the chain is too easy for an honest share here; the pool would refuse " - "and it would be right to. Re-run Cell 4 until the tip is real.") + die("The chain is too easy for an honest share here; the pool would refuse " + "and it would be right to. Re-run Cell 6 until the tip is real.") procs = [] def spawn(argv, cwd, log): @@ -402,10 +1843,10 @@ try: # share factor actually served, and a tail # window can start after it. if pool.poll() is not None: - raise SystemExit("the pool exited during startup; its message is above") + die("The pool exited during startup; its message is above.") if "caps it at" in poollog: - raise SystemExit("the pool capped the share factor below what was asked, so the served " - "share target is at its ceiling and the split would be meaningless") + die("The pool capped the share factor below what was asked, so the served " + "share target is at its ceiling and the split would be meaningless.") miner = spawn(["./poworker"], D, "/content/miner.log") RIVAL_CFG = D + "/cpurival/poworker.config.ini" @@ -421,12 +1862,12 @@ try: gpu = open("/content/miner.log").read() if RIVAL_CFG not in riv: print(riv[:1200]) - raise SystemExit("the rival did not load " + RIVAL_CFG + "; its log is above") + die("The rival did not load " + RIVAL_CFG + "; its log is above.") if re.search(r"Create CUDA block miner worker|\[CUDA\] Device #", riv): - raise SystemExit("the rival came up as a CUDA worker; it must be the CPU control") + die("The rival came up as a CUDA worker; it must be the CPU control.") if "Create CUDA block miner worker" not in gpu: print(gpu[:1200]) - raise SystemExit("the GPU miner never created a CUDA worker; its log is above") + die("The GPU miner never created a CUDA worker; its log is above.") print("CUDA miner and single-thread CPU rival running. Sampling for %d minutes..." % SAMPLE_MINUTES) @@ -436,9 +1877,9 @@ try: # A dead miner must end the run, not produce ten minutes of zeroes. The # rival dying is the failure mode that reads as a PERFECT score. if miner.poll() is not None: - raise SystemExit("the CUDA miner exited at minute %d" % (i + 1)) + die("The CUDA miner exited at minute %d." % (i + 1)) if rival.poll() is not None: - raise SystemExit("the CPU rival exited at minute %d, so there is no control" % (i + 1)) + die("The CPU rival exited at minute %d, so there is no control." % (i + 1)) try: stats = get("http://127.0.0.1:18082/stats") except Exception as e: @@ -474,7 +1915,7 @@ finally: --- -## Cell 6: the raw counts +## Cell 8: the raw counts ```python import re @@ -519,7 +1960,7 @@ print("\n".join(re.findall(r"\[Mining\] height .*", log))[:1500]) --- -## Cell 7: the share list, and whether the card is paid for what it mines +## Cell 9: the share list, and whether the card is paid for what it mines The defect this targets: the batch kernel found thousands of payable nonces per batch and the miner reported only the single minimum from the tree reduction. @@ -552,6 +1993,9 @@ the split measured over the whole sample matches the window snapshot exactly. Al five checks passed, with no stale submissions, no failed submissions and no share list overflow. +That run predates the gate, so it is a payment result and not a correctness one. +Nothing in it says the card computed the right hashes; Cell 2 is what says that. + Two things about those numbers are worth stating so they are not misread later. The 6.92 MH/s is not a regression against the 86 MH/s seen on the same card in @@ -567,7 +2011,7 @@ PRECISION is bounded by that count rather than by the GPU's. A longer sample, or rival with more threads, tightens it. Two other things this particular run did not exercise: only one height change was observed, so template rolling and the stale path were barely touched, and the share list never overflowed, so the -undersampling path was not reached outside the Cell 2 unit test that covers it +undersampling path was not reached outside the Cell 3 unit test that covers it directly. ```python @@ -604,10 +2048,10 @@ stats = json.load(open("/content/final_stats.json")) # before believing a single number in them. run_id = open("/content/run_id.txt").read().strip() assert stats.get("run_id") == run_id, \ - "final_stats.json is from run %s, not %s: Cell 5 aborted before finishing" % ( + "final_stats.json is from run %s, not %s: Cell 7 aborted before finishing" % ( stats.get("run_id"), run_id) assert time.time() - os.path.getmtime("/content/final_stats.json") < 3600, \ - "these results are over an hour old; re-run Cell 5" + "these results are over an hour old; re-run Cell 7" counts = dict((a, n) for a, n in stats["workers"]) gpu_submits = len(re.findall(r"submit/miner/success", gpu_log)) @@ -698,6 +2142,7 @@ print() if all(verdict): print("RESULT: PASS. The CUDA share list is reporting every payable nonce, and") print("the card is being paid in proportion to the work it did.") + print("This is a PAYMENT result. Correctness is Cell 2's claim, not this one's.") else: print("RESULT: FAIL. Do not ship this. A failing window-share line with a") print("healthy hashrate is the original defect: the card mines and the pool") @@ -708,7 +2153,7 @@ if undersample: print("NOTE: the share list filled up, so the kernel counted more payable nonces than") print("one batch can hand back. That is the fix WORKING and saying so, and the session") print("figure above is income the miner did not claim. The remedy is a HARDER share") - print("target, which means a LOWER share_bits in Cell 5. Raising it makes shares easier") + print("target, which means a LOWER share_bits in Cell 7. Raising it makes shares easier") print("and overflows sooner; past the point where the derivation saturates it does") print("nothing at all, because the ceiling is the chain's and not yours.") ``` @@ -723,7 +2168,7 @@ if undersample: that `share_capacity` reaches the kernel non-zero, that is, that the miner really is pooled. - **Window share FAILs while submission volume PASSes.** Submissions are being - made but not credited. Look at `kind=stale` in Cell 6 and at the pool log: a + made but not credited. Look at `kind=stale` in Cell 8 and at the pool log: a submit-gate or template-freshness problem, not a kernel problem. - **"the control actually ran" FAILs.** The rival was not a control. Nothing else in the report can be trusted, because a dead rival and a perfect result are the @@ -732,7 +2177,8 @@ if undersample: the card reported, or the card listed a nonce whose hash does not beat the share target. Every share list entry is re-hashed on the CPU before it can reach the pool, and one bad entry fails the whole batch by design. Hardware or kernel - fault, never something to work around. + fault, never something to work around. If Cell 2 passed and this appears, + suspect the hardware. ### Why the absolute numbers differ from AMD @@ -742,6 +2188,38 @@ are the hardware-independent test and are the ones to trust: they compare this card against a CPU thread on the same box, in the same PPLNS window, which is exactly how the defect was found. +Absolute hashrates from this notebook also carry the reduced build profile from +Cell 1 (LTO off, opt-level 2, chosen so free Colab can finish the build at all). +The GPU kernels are nvcc `-O3` regardless, so the CUDA figure moves little; the +CPU rival's does. The split is unaffected because both sides are built the same +way. + +--- + +## Collect the evidence before the session dies + +```python +from pathlib import Path +import shutil, time +OUT = Path("/content/colab-evidence-%s" % time.strftime("%Y%m%dT%H%M%S")) +OUT.mkdir() +for p in ["/content/node.log", "/content/pool.log", "/content/miner.log", + "/content/cpu.log", "/content/final_stats.json", "/content/run_id.txt", + "/content/sync.json"]: + if Path(p).is_file(): + shutil.copy(p, OUT) +res = Path("/content/fullnodedev/scripts/mining-nvidia/colab-results") +if res.is_dir(): + shutil.copytree(res, OUT / "colab-results") +print("collected into", OUT) +print("\n".join(str(p) for p in sorted(OUT.rglob("*")))) +# Download from the file browser on the left, or: +# from google.colab import files; shutil.make_archive(str(OUT), "zip", OUT); files.download(str(OUT) + ".zip") +``` + +The gate log is the one to keep. It is the only artefact that says the hashes +were right, and it names the commit it says it about. + --- ## What this rig does not show @@ -755,3 +2233,10 @@ Nothing in this document adds a second block producer either. `[miner] enable` o the node does NOT start a node-side hasher: it gates the two miner API routes and sizes the transaction pool, and that is all. The settlement path is exercised separately, on a rig where blocks can actually be won. + +The gate does not test the miner's own launch shape end to end either. It tests +the kernel at that shape (`48x256x48` by default, and `PROD_WG`/`PROD_UNIT` +change it), but `poworker` in Cell 5 is configured `256x256x16`. Those are +different launch geometries of the same kernel. If you want the gate to cover the +exact shape the miner will run, set `PROD_WG=256 PROD_UNIT=16` in Cell 2 and +expect the CPU oracle to take proportionally longer. diff --git a/scripts/mining-nvidia/colab_cuda_smoke.ipynb b/scripts/mining-nvidia/colab_cuda_smoke.ipynb index 32ab7b7c..5d0ad748 100644 --- a/scripts/mining-nvidia/colab_cuda_smoke.ipynb +++ b/scripts/mining-nvidia/colab_cuda_smoke.ipynb @@ -1,160 +1,473 @@ { - "nbformat": 4, - "nbformat_minor": 5, - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "pygments_lexer": "ipython3" - }, - "accelerator": "GPU" + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hacash CUDA on a Colab T4: prove the kernels, then measure them\n", + "\n", + "**Runtime -> Change runtime type -> T4 GPU** before running anything.\n", + "\n", + "This notebook is the correctness half. It does **not** sync mainnet and does not\n", + "measure a hashrate; for that see `scripts/mining-nvidia/colab_cuda_pool_e2e.md`,\n", + "which runs the same gate as its Cell 2 and only then measures.\n", + "\n", + "## What it proves, in order\n", + "\n", + "1. This runtime really has an NVIDIA GPU and a CUDA toolkit.\n", + "2. The tree on this VM is the commit you meant **and contains the CUDA gate**.\n", + " Landing on the right commit is not the same thing: the CUDA half of the gate\n", + " is newer than the last push, so a clone of an older commit has nothing to run.\n", + "3. **The gate.** Every hash the card computes equals `x16rs::block_hash`, byte\n", + " for byte, at repeat 1/4/8/16 across three launch shapes plus the production\n", + " shape. And the gate is shown to CATCH kernels that are broken on purpose,\n", + " because a gate that has only ever passed proves nothing about the kernel.\n", + "4. The `x16rs-cuda` suite: the genesis vector, the differential tests, and the\n", + " pool share list's bookkeeping.\n", + "\n", + "## PASS looks like\n", + "\n", + "`GATE: PASS` at step 3, with `faults caught: 3/3`, and `result=PASS` in\n", + "`scripts/mining-nvidia/colab-results/latest-gate-summary.txt`.\n", + "\n", + "## FAIL looks like\n", + "\n", + "A bordered `STOP` block and a halted notebook. There is no path past a failed\n", + "gate. `PASS-UNPROVEN` and `PASS-RACE-NOT-REPRODUCED` are weaker results that let\n", + "you continue with a printed warning; quote the string, never the word PASS." + ] }, - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hacash CUDA smoke (Colab T4)\n", - "\n", - "**Phase 1 only:** build + unit tests for `x16rs-cuda` and `poworker --features cuda`.\n", - "\n", - "- Does **not** sync mainnet.\n", - "- Does **not** push to GitHub.\n", - "- **Runtime → Change runtime type → T4 GPU** before running.\n", - "\n", - "## PASS criteria\n", - "1. `nvidia-smi` shows a GPU (T4 is fine, sm_75).\n", - "2. `cargo test -p x16rs-cuda --features cuda` passes (genesis + CPU/GPU match + batch).\n", - "3. `cargo build --release --bin poworker --features cuda` succeeds.\n", - "4. Log files under `scripts/mining-nvidia/colab-results/`." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1) GPU check" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ - "!nvidia-smi" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2) Get the repo\n", - "\n", - "Option A: clone your public fork (edit URL if needed).\n", - "\n", - "Option B: upload a zip of the repo to Colab and unzip, then set `REPO_DIR`." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ - "import os\n", - "from pathlib import Path\n", - "\n", - "# --- edit if needed ---\n", - "REPO_URL = \"https://github.com/Moskyera/fullnodedev.git\"\n", - "BRANCH = \"main\" # or your working branch with x16rs-cuda\n", - "REPO_DIR = Path(\"/content/fullnodedev\")\n", - "\n", - "if not REPO_DIR.exists():\n", - " !git clone --depth 1 -b {BRANCH} {REPO_URL} {REPO_DIR}\n", - "else:\n", - " print(\"Repo already present:\", REPO_DIR)\n", - "\n", - "os.chdir(REPO_DIR)\n", - "print(\"cwd:\", Path.cwd())\n", - "assert (REPO_DIR / \"x16rs-cuda\").is_dir(), \"x16rs-cuda missing — wrong branch or incomplete tree\"\n", - "assert (REPO_DIR / \"scripts/mining-nvidia/colab_cuda_smoke.sh\").is_file(), \"smoke script missing — pull latest Phase 1 files\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3) Run smoke script (FREE TIER)\n", - "\n", - "Default = **only CUDA crate tests** (~10–25 min). Not full poworker release.\n", - "\n", - "You should see `still working...` every minute. If silent for 10+ min, interrupt and re-run.\n", - "\n", - "If you still have the **old** never-ending build: **Runtime → Interrupt execution**, then run this cell." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ - "import os\n", - "from pathlib import Path\n", - "\n", - "# Prefer slim unpack path; fall back to clone path\n", - "for d in (\"/content/hacash-fullnodedev\", \"/content/fullnodedev\"):\n", - " if Path(d).is_dir():\n", - " os.chdir(d)\n", - " break\n", - "print(\"cwd:\", Path.cwd())\n", - "\n", - "!chmod +x scripts/mining-nvidia/colab_cuda_smoke.sh\n", - "# FREE tier: do NOT set COLAB_FULL=1\n", - "!bash scripts/mining-nvidia/colab_cuda_smoke.sh\n", - "\n", - "summary = Path(\"scripts/mining-nvidia/colab-results/latest-summary.txt\")\n", - "if summary.is_file():\n", - " print(\"--- latest-summary.txt ---\")\n", - " print(summary.read_text())\n", - "else:\n", - " print(\"No summary file — script failed early\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 4) Download evidence (optional)\n", - "\n", - "After PASS, download logs from the left file browser:\n", - "`fullnodedev/scripts/mining-nvidia/colab-results/`\n", - "\n", - "Keep them offline. **Do not publish a production CUDA claim without these logs.**" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "from google.colab import files\n", - "\n", - "results = Path(\"/content/fullnodedev/scripts/mining-nvidia/colab-results\")\n", - "if results.is_dir():\n", - " for p in sorted(results.glob(\"*\")):\n", - " print(\"download:\", p)\n", - " files.download(str(p))\n", - "else:\n", - " print(\"No results dir yet\")" - ] - } - ] + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1) Is this actually a GPU runtime?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, shutil, subprocess\n", + "\n", + "def die(msg):\n", + " \"\"\"Stop the notebook loudly.\n", + "\n", + " A bare `!command` that exits nonzero does NOT stop a Colab notebook: IPython\n", + " ignores the status and the next cell runs on whatever the last successful run\n", + " left behind. Every check here therefore raises.\"\"\"\n", + " lines = msg.strip().splitlines()\n", + " print(\"\\n\" + \"#\" * 72)\n", + " print(\"# STOP\")\n", + " for line in lines:\n", + " print(\"# \" + line)\n", + " print(\"#\" * 72)\n", + " raise RuntimeError(lines[0])\n", + "\n", + "if shutil.which(\"nvidia-smi\") is None:\n", + " die(\"\"\"This runtime has no NVIDIA GPU.\n", + "Runtime -> Change runtime type -> T4 GPU, then run this cell again.\"\"\")\n", + "\n", + "print(subprocess.run([\"nvidia-smi\", \"--query-gpu=name,memory.total,driver_version\",\n", + " \"--format=csv\"], capture_output=True, text=True).stdout)\n", + "\n", + "nvcc = shutil.which(\"nvcc\") or \"/usr/local/cuda/bin/nvcc\"\n", + "if not os.path.exists(nvcc):\n", + " die(\"\"\"No nvcc. The CUDA toolkit is normally at /usr/local/cuda on Colab.\n", + "Without it x16rs-cuda/build.rs compiles a crate with NO kernels in it: every\n", + "device call returns NotCompiled and a gate run would compare zero hashes.\"\"\")\n", + "print(subprocess.run([nvcc, \"--version\"], capture_output=True, text=True).stdout)\n", + "print(\"CPU cores (the gate hashes its CPU oracle on these):\", os.cpu_count())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2) Get the code, and check it is code that can be gated\n", + "\n", + "Two independent failures live here.\n", + "\n", + "`git clone` is skipped when the directory already exists, so a session that\n", + "survived a runtime restart silently re-tests an old commit. The fetch and reset\n", + "fix that, and the commit is printed so it lands in the log.\n", + "\n", + "Landing on a commit is not the same as landing on code that contains the gate.\n", + "The check below looks for each piece by name and says which one is missing.\n", + "\n", + "**No push yet?** Upload\n", + "`scripts/mining-nvidia/colab-upload/hacash-fullnodedev-colab-slim.zip` from\n", + "`pack-colab-slim.ps1`, run\n", + "`!unzip -q hacash-fullnodedev-colab-slim.zip -d /content`, and this cell finds\n", + "`/content/hacash-fullnodedev` and skips the git steps. That zip has no `.git`, so\n", + "`COLAB-PACK-STAMP.txt` is what identifies it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, subprocess, pathlib\n", + "\n", + "def die(msg):\n", + " \"\"\"Stop the notebook loudly.\n", + "\n", + " A bare `!command` that exits nonzero does NOT stop a Colab notebook: IPython\n", + " ignores the status and the next cell runs on whatever the last successful run\n", + " left behind. Every check here therefore raises.\"\"\"\n", + " lines = msg.strip().splitlines()\n", + " print(\"\\n\" + \"#\" * 72)\n", + " print(\"# STOP\")\n", + " for line in lines:\n", + " print(\"# \" + line)\n", + " print(\"#\" * 72)\n", + " raise RuntimeError(lines[0])\n", + "\n", + "def find_repo():\n", + " \"\"\"A clone lands in /content/fullnodedev; the slim zip unpacks to\n", + " /content/hacash-fullnodedev. Accept either.\"\"\"\n", + " for d in (\"/content/fullnodedev\", \"/content/hacash-fullnodedev\"):\n", + " if os.path.isdir(d) and os.path.isfile(os.path.join(d, \"Cargo.toml\")):\n", + " return d\n", + " return None\n", + "\n", + "REPO = \"https://github.com/Moskyera/fullnodedev.git\"\n", + "BRANCH = \"feat/pool-directory-cuda-ptx-panel\"\n", + "CLONE = \"/content/fullnodedev\"\n", + "\n", + "def sh(cmd, cwd=None, what=None):\n", + " print(\"$\", cmd)\n", + " rc = subprocess.run(cmd, shell=True, cwd=cwd, executable=\"/bin/bash\").returncode\n", + " if rc != 0:\n", + " die(\"%s failed (exit %d).\\ncommand: %s\" % (what or \"a shell step\", rc, cmd))\n", + "\n", + "D = find_repo()\n", + "if D is None:\n", + " sh(\"git clone --depth 1 -b %s %s %s\" % (BRANCH, REPO, CLONE), what=\"git clone\")\n", + " D = CLONE\n", + "\n", + "if os.path.isdir(os.path.join(D, \".git\")):\n", + " sh(\"git fetch --depth 1 origin %s\" % BRANCH, cwd=D, what=\"git fetch\")\n", + " sh(\"git reset --hard FETCH_HEAD\", cwd=D, what=\"git reset\")\n", + " info = subprocess.run(\"git log -1 --format='%H%n%h%n%ad%n%s' --date=iso\",\n", + " shell=True, cwd=D, capture_output=True,\n", + " text=True).stdout.split(\"\\n\")\n", + " dirty = subprocess.run(\"git status --porcelain\", shell=True, cwd=D,\n", + " capture_output=True, text=True).stdout.strip()\n", + " print()\n", + " print(\"repo :\", D)\n", + " print(\"commit :\", info[0])\n", + " print(\"date :\", info[2])\n", + " print(\"subject :\", info[3])\n", + " print(\"tree :\", \"clean\" if not dirty else \"DIRTY\\n\" + dirty)\n", + "else:\n", + " stamp = pathlib.Path(D) / \"COLAB-PACK-STAMP.txt\"\n", + " print()\n", + " print(\"repo :\", D, \"(uploaded zip, no git)\")\n", + " print(stamp.read_text() if stamp.is_file()\n", + " else \"NO COLAB-PACK-STAMP.txt: this zip cannot say what commit it came from\")\n", + "\n", + "os.environ[\"HACASH_REPO\"] = D\n", + "\n", + "# Each entry is (path, text that must appear in it, why it matters).\n", + "REQUIRED = [\n", + " (\"scripts/mining-nvidia/colab_cuda_gate.sh\", None,\n", + " \"the gate runner step 3 calls\"),\n", + " (\"app/src/x16rs_gate.rs\", \"CudaBackend\",\n", + " \"the CUDA backend of the gate. Without it there is no --backend cuda, and the \"\n", + " \"gate can only test OpenCL, which this runtime does not have\"),\n", + " (\"src/bin/x16rs_gate.rs\", \"--backend\",\n", + " \"the flag that selects the backend\"),\n", + " (\"x16rs-cuda/build.rs\", \"X16RS_CUDA_KERNEL_DIR\",\n", + " \"the knob that rebuilds the CUDA kernels from a deliberately broken tree. \"\n", + " \"Without it the CUDA gate can never be shown to FAIL\"),\n", + " (\"x16rs/opencl/x16rs.cl\", \"X16RS_H_BLAKE_INIT\",\n", + " \"blake's initialisation vector, exported so block_miner.cu reads it instead of \"\n", + " \"carrying its own copy. Without this, fault C flips a bit in x16rs.cl, the .cu \"\n", + " \"keeps the old value, the PTX is byte-identical, and the gate returns PASS for a \"\n", + " \"kernel broken on purpose\"),\n", + " (\"scripts/x16rs_gate_trees.py\", \"faults\",\n", + " \"the three fault trees\"),\n", + "]\n", + "\n", + "missing = []\n", + "for path, needle, why in REQUIRED:\n", + " p = pathlib.Path(D) / path\n", + " if not p.is_file():\n", + " missing.append(\"%s is not in this tree\\n (%s)\" % (path, why))\n", + " elif needle and needle not in p.read_text(errors=\"replace\"):\n", + " missing.append(\"%s exists but does not contain %r\\n (%s)\" % (path, needle, why))\n", + "\n", + "if missing:\n", + " die(\"\"\"This tree predates the CUDA gate, so step 3 has nothing to run.\n", + "\n", + "Missing:\n", + " \"\"\" + \"\\n \".join(missing) + \"\"\"\n", + "\n", + "Either push the branch that carries the CUDA gate, or use the zip route\n", + "(pack-colab-slim.ps1), which refuses to pack a tree missing any of these.\"\"\")\n", + "\n", + "print(\"\\nall %d required pieces of the CUDA gate are present\" % len(REQUIRED))\n", + "\n", + "if not os.path.exists(os.path.expanduser(\"~/.cargo/env\")):\n", + " sh(\"curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal\",\n", + " what=\"rustup install\")\n", + "print(\"rust:\", subprocess.run(\"~/.cargo/bin/rustc --version || rustc --version\",\n", + " shell=True, capture_output=True, text=True).stdout.strip())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3) The gate. Nothing below matters until this passes\n", + "\n", + "Builds the gate, proves the shipping kernels equal the CPU byte for byte, then\n", + "rebuilds three more times against kernel trees that are wrong on purpose and\n", + "requires the gate to catch each one.\n", + "\n", + "**Resumable.** Each step writes a marker under `target/gate-state//`\n", + "and a re-run skips it, so a T4 session that dies during fault B costs you fault\n", + "B and not the whole run. The fingerprint covers the commit, the kernel sources\n", + "and the gate sources, so editing any of them starts over by itself.\n", + "\n", + "**Roughly 25 to 45 minutes on a free T4**, dominated by four cargo builds. A\n", + "heartbeat with elapsed time prints every 60 seconds, so a quiet cell is\n", + "distinguishable from a dead one. Uncomment `SKIP_FAULTS` below for the\n", + "equivalence half only: about a quarter of the time and a quarter of the proof.\n", + "\n", + "Fault B is a deleted barrier, which is a data race. It was caught on an AMD\n", + "gfx1201; whether an NVIDIA scheduler reproduces it has never been observed here.\n", + "If A and C are caught and B is not, set `ALLOW_RACE_MISS=1` to continue. The\n", + "result string becomes `PASS-RACE-NOT-REPRODUCED` and stays that way in the\n", + "summary file, so the run can never later be quoted as a clean PASS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, subprocess, pathlib\n", + "\n", + "def die(msg):\n", + " \"\"\"Stop the notebook loudly.\n", + "\n", + " A bare `!command` that exits nonzero does NOT stop a Colab notebook: IPython\n", + " ignores the status and the next cell runs on whatever the last successful run\n", + " left behind. Every check here therefore raises.\"\"\"\n", + " lines = msg.strip().splitlines()\n", + " print(\"\\n\" + \"#\" * 72)\n", + " print(\"# STOP\")\n", + " for line in lines:\n", + " print(\"# \" + line)\n", + " print(\"#\" * 72)\n", + " raise RuntimeError(lines[0])\n", + "\n", + "def find_repo():\n", + " \"\"\"A clone lands in /content/fullnodedev; the slim zip unpacks to\n", + " /content/hacash-fullnodedev. Accept either.\"\"\"\n", + " for d in (\"/content/fullnodedev\", \"/content/hacash-fullnodedev\"):\n", + " if os.path.isdir(d) and os.path.isfile(os.path.join(d, \"Cargo.toml\")):\n", + " return d\n", + " return None\n", + "\n", + "D = os.environ.get(\"HACASH_REPO\") or find_repo()\n", + "if D is None:\n", + " die(\"No repo on this VM. Run step 2 first.\")\n", + "\n", + "GATE_ENV = dict(os.environ)\n", + "GATE_ENV[\"CUDA_PATH\"] = \"/usr/local/cuda\"\n", + "GATE_ENV[\"PATH\"] = os.environ[\"PATH\"] + \":/usr/local/cuda/bin:\" + os.path.expanduser(\"~/.cargo/bin\")\n", + "# GATE_ENV[\"SKIP_FAULTS\"] = \"1\" # equivalence only, no fault injection\n", + "# GATE_ENV[\"ALLOW_RACE_MISS\"] = \"1\" # see the note above about fault B\n", + "# GATE_ENV[\"RESUME\"] = \"0\" # redo every step from scratch\n", + "\n", + "# Streamed line by line rather than captured: a captured build looks identical to\n", + "# a hung one for twenty minutes.\n", + "proc = subprocess.Popen([\"bash\", \"scripts/mining-nvidia/colab_cuda_gate.sh\"],\n", + " cwd=D, env=GATE_ENV, stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + "for line in proc.stdout:\n", + " print(line, end=\"\")\n", + "rc = proc.wait()\n", + "\n", + "summary = pathlib.Path(D) / \"scripts/mining-nvidia/colab-results/latest-gate-summary.txt\"\n", + "if not summary.is_file():\n", + " die(\"The gate wrote no summary file, so it died before reaching a verdict \"\n", + " \"(exit %d). Its output is above.\" % rc)\n", + "kv = dict(l.split(\"=\", 1) for l in summary.read_text().splitlines() if \"=\" in l)\n", + "result = kv.get(\"result\", \"MISSING\")\n", + "\n", + "# The summary is overwritten in place, so an aborted run can leave the PREVIOUS\n", + "# verdict sitting there looking current. Tie it to this checkout.\n", + "head = subprocess.run(\"git rev-parse --short HEAD\", shell=True, cwd=D,\n", + " capture_output=True, text=True).stdout.strip() or \"not-a-git-checkout\"\n", + "if kv.get(\"commit\") != head:\n", + " die(\"The gate summary says commit %s, but this checkout is %s, so that file is \"\n", + " \"left over from an earlier run and its verdict is not about this code.\"\n", + " % (kv.get(\"commit\", \"\"), head))\n", + "\n", + "print()\n", + "print(\"=\" * 72)\n", + "if rc != 0 or result.startswith(\"FAIL\") or result == \"MISSING\":\n", + " die(\"\"\"GATE: %s (exit %d)\n", + "reason: %s\n", + "\n", + "The CUDA kernels on this card are NOT proven to compute x16rs::block_hash.\n", + "Do not run the miner and do not report a hashrate from this build.\n", + "Full log: %s\"\"\" % (result, rc, kv.get(\"reason\", \"see the output above\"),\n", + " kv.get(\"log\", \"?\")))\n", + "\n", + "print(\"GATE:\", result)\n", + "print(\"faults caught:\", kv.get(\"faults_caught\", \"n/a\"))\n", + "print(\"commit:\", kv.get(\"commit\"), \" log:\", kv.get(\"log\"))\n", + "if result != \"PASS\":\n", + " print()\n", + " print(\"!\" * 72)\n", + " print(\"! This is NOT a clean PASS. It is:\", result)\n", + " if result == \"PASS-UNPROVEN\":\n", + " print(\"! The kernels matched the CPU, but SKIP_FAULTS=1 meant the gate was never\")\n", + " print(\"! shown to be able to catch a broken kernel on this box.\")\n", + " if result == \"PASS-RACE-NOT-REPRODUCED\":\n", + " print(\"! The arithmetic faults were caught. The data-race fault (%s) did not\"\n", + " % kv.get(\"race_not_reproduced\", \"B\"))\n", + " print(\"! reproduce on this card and was waived by ALLOW_RACE_MISS=1.\")\n", + " print(\"! Quote the result string, not the word PASS.\")\n", + " print(\"!\" * 72)\n", + "print(\"=\" * 72)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4) The `x16rs-cuda` suite\n", + "\n", + "Covers what the gate does not: the share list's bookkeeping (overflow, counter\n", + "isolation between a pooled batch and the next solo one, readback bounds) and the\n", + "guard that keeps fault C meaningful.\n", + "\n", + "The script checks that the GPU tests actually RAN. Two traps make a green run\n", + "here meaningless: `--features cuda` is not optional, and `gpu_share_list_tests`\n", + "is `#[cfg(all(test, cuda_available))]`, so without nvcc it is not compiled at all\n", + "and its absence is silent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, subprocess, pathlib\n", + "\n", + "def die(msg):\n", + " \"\"\"Stop the notebook loudly.\n", + "\n", + " A bare `!command` that exits nonzero does NOT stop a Colab notebook: IPython\n", + " ignores the status and the next cell runs on whatever the last successful run\n", + " left behind. Every check here therefore raises.\"\"\"\n", + " lines = msg.strip().splitlines()\n", + " print(\"\\n\" + \"#\" * 72)\n", + " print(\"# STOP\")\n", + " for line in lines:\n", + " print(\"# \" + line)\n", + " print(\"#\" * 72)\n", + " raise RuntimeError(lines[0])\n", + "\n", + "def find_repo():\n", + " \"\"\"A clone lands in /content/fullnodedev; the slim zip unpacks to\n", + " /content/hacash-fullnodedev. Accept either.\"\"\"\n", + " for d in (\"/content/fullnodedev\", \"/content/hacash-fullnodedev\"):\n", + " if os.path.isdir(d) and os.path.isfile(os.path.join(d, \"Cargo.toml\")):\n", + " return d\n", + " return None\n", + "\n", + "D = os.environ.get(\"HACASH_REPO\") or find_repo()\n", + "if D is None:\n", + " die(\"No repo on this VM. Run step 2 first.\")\n", + "\n", + "env = dict(os.environ)\n", + "env[\"CUDA_PATH\"] = \"/usr/local/cuda\"\n", + "env[\"PATH\"] = os.environ[\"PATH\"] + \":/usr/local/cuda/bin:\" + os.path.expanduser(\"~/.cargo/bin\")\n", + "\n", + "proc = subprocess.Popen([\"bash\", \"scripts/mining-nvidia/colab_cuda_smoke.sh\"],\n", + " cwd=D, env=env, stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + "for line in proc.stdout:\n", + " print(line, end=\"\")\n", + "rc = proc.wait()\n", + "\n", + "summary = pathlib.Path(D) / \"scripts/mining-nvidia/colab-results/latest-summary.txt\"\n", + "print()\n", + "if summary.is_file():\n", + " print(\"--- latest-summary.txt ---\")\n", + " print(summary.read_text())\n", + "if rc != 0:\n", + " die(\"The crate smoke FAILED (exit %d). Its output is above.\" % rc)\n", + "print(\"crate smoke: PASS\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5) Collect the evidence before the session dies\n", + "\n", + "The gate log is the one to keep. It is the only artefact that says the hashes\n", + "were right, and it names the commit it says it about." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os, shutil, time\n", + "from pathlib import Path\n", + "\n", + "def find_repo():\n", + " for d in (\"/content/fullnodedev\", \"/content/hacash-fullnodedev\"):\n", + " if os.path.isdir(d) and os.path.isfile(os.path.join(d, \"Cargo.toml\")):\n", + " return d\n", + " return None\n", + "\n", + "D = os.environ.get(\"HACASH_REPO\") or find_repo()\n", + "res = Path(D) / \"scripts/mining-nvidia/colab-results\" if D else None\n", + "if res is None or not res.is_dir():\n", + " print(\"No results directory yet; nothing has run.\")\n", + "else:\n", + " for p in sorted(res.glob(\"*\")):\n", + " print(\"%10d bytes %s\" % (p.stat().st_size, p.name))\n", + " OUT = \"/content/colab-gate-evidence-%s\" % time.strftime(\"%Y%m%dT%H%M%S\")\n", + " shutil.make_archive(OUT, \"zip\", res)\n", + " print(\"\\npacked:\", OUT + \".zip\")\n", + " print(\"Download it from the file browser on the left, or uncomment:\")\n", + " print(\"# from google.colab import files; files.download(%r)\" % (OUT + \".zip\"))" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "provenance": [], + "gpuType": "T4" + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 } diff --git a/scripts/mining-nvidia/colab_cuda_smoke.sh b/scripts/mining-nvidia/colab_cuda_smoke.sh index f20c6943..92ded95a 100644 --- a/scripts/mining-nvidia/colab_cuda_smoke.sh +++ b/scripts/mining-nvidia/colab_cuda_smoke.sh @@ -1,7 +1,18 @@ #!/usr/bin/env bash -# Phase 1: CUDA smoke for Google Colab FREE TIER (T4) or any Linux NVIDIA box. +# CUDA crate smoke for Google Colab FREE TIER (T4) or any Linux NVIDIA box. # -# FREE TIER MODE (default): only x16rs-cuda unit tests (proves GPU kernels). +# THIS IS NOT THE EQUIVALENCE GATE. It runs the x16rs-cuda test suite: the +# genesis vector, the differential tests over a few thousand inputs, and the pool +# share list's bookkeeping (overflow, counter isolation, readback bounds). That +# is real coverage and it is worth having, but the claim "every hash this card +# computes equals x16rs::block_hash" comes from +# +# bash scripts/mining-nvidia/colab_cuda_gate.sh +# +# which compares whole windows byte for byte and is itself proved able to fail. +# Run the gate first; run this after it. +# +# FREE TIER MODE (default): tests only. # Skips full poworker --release (that can run for hours with workspace LTO). # # Full mode (optional, Pro / long session): @@ -39,7 +50,7 @@ SUMMARY="${LOG_DIR}/latest-summary.txt" exec > >(tee -a "$LOG") 2>&1 echo "==============================================" -echo " Hacash CUDA smoke — ${MODE}" +echo " Hacash CUDA smoke: ${MODE}" echo " time (UTC): ${STAMP}" echo " repo: ${ROOT}" echo " log: ${LOG}" @@ -126,8 +137,17 @@ fi echo "" echo "=== [required] cargo test -p x16rs-cuda --features cuda ===" echo " (debug build; NOT full miner; free-tier safe)" -if run_with_heartbeat "x16rs-cuda-tests" \ - cargo test -p x16rs-cuda --features cuda -- --nocapture; then + +# The guards below read the test output back. They must NOT read "$LOG": that +# file is written by the `exec > >(tee ...)` at the top, whose tee is a separate +# process, so a grep run microseconds after the test finishes can read a file +# that is still short. Capture the test's own output to its own file and grep +# that; the pipeline's status is cargo's because pipefail is set at the top. +TEST_LOG="${LOG_DIR}/tests-${STAMP}.log" +run_cuda_tests() { + cargo test -p x16rs-cuda --features cuda -- --nocapture 2>&1 | tee "$TEST_LOG" +} +if run_with_heartbeat "x16rs-cuda-tests" run_cuda_tests; then TEST_RC=0 echo " PASS: x16rs-cuda tests" pass=$((pass + 1)) @@ -136,13 +156,39 @@ else echo " FAIL: x16rs-cuda tests (exit ${TEST_RC})" fail=$((fail + 1)) fi - -# Guard: if kernels were stubs, tests may "pass" by skipping — detect skip spam -if grep -q "CUDA kernels not compiled" "$LOG"; then - echo " FAIL: kernels not compiled (install/path issue)" - fail=$((fail + 1)) - TEST_RC=1 -fi +[[ -f "$TEST_LOG" ]] || : > "$TEST_LOG" + +# Guard 1: a test that SKIPS still counts as a pass. Every wording the suite +# uses to decline has to be caught, not just one of them. +for excuse in "CUDA kernels not compiled" "no usable CUDA device" "skipping"; do + if grep -q "$excuse" "$TEST_LOG"; then + echo " FAIL: a GPU test declined to run (\"${excuse}\"), so this green result" + echo " covers nothing about the card." + fail=$((fail + 1)) + TEST_RC=1 + break + fi +done + +# Guard 2: the worse case, where a test does not skip because it was never +# compiled. gpu_share_list_tests is #[cfg(all(test, cuda_available))] and +# cuda_available is set by build.rs ONLY when it found nvcc. Without nvcc the +# module vanishes and cargo prints a tidy "ok" for what is left, with no excuse +# to grep for. The only reliable check is that the test NAMES appear. +for want in \ + "cuda_matches_cpu_across_many_inputs" \ + "cuda_batch_matches_cpu" \ + "cuda_genesis_block_hash_when_available" \ + "the_share_list_matches_the_cpu_and_leaves_the_best_result_untouched" \ + "the_blake_iv_is_not_duplicated_into_the_cuda_source"; do + if ! grep -q "$want" "$TEST_LOG"; then + echo " FAIL: ${want} never ran." + echo " Look for the cargo warning \"Using CUDA Toolkit at ...\" above. If it says" + echo " \"CUDA Toolkit not found\" instead, the GPU tests were never compiled." + fail=$((fail + 1)) + TEST_RC=1 + fi +done # --- OPTIONAL full poworker (skip on free tier) --- if [[ "$COLAB_FULL" == "1" ]]; then @@ -198,7 +244,7 @@ if [[ "$RESULT" == "PASS" ]]; then echo "" echo "OVERALL: PASS (CUDA kernels validated on this GPU)" echo "Download: scripts/mining-nvidia/colab-results/" - echo "No push required yet — keep the logs." + echo "No push required yet; keep the logs." exit 0 fi diff --git a/scripts/mining-nvidia/pack-colab-slim.ps1 b/scripts/mining-nvidia/pack-colab-slim.ps1 index 0b39971d..56b1c021 100644 --- a/scripts/mining-nvidia/pack-colab-slim.ps1 +++ b/scripts/mining-nvidia/pack-colab-slim.ps1 @@ -90,10 +90,19 @@ Get-ChildItem -LiteralPath $Root -Force -ErrorAction SilentlyContinue | ForEach- Copy-Slim $_.FullName $Stage $Root } -# Ensure smoke scripts are present +# Ensure the scripts are present. The zip carries no .git, so nothing downstream +# can tell what commit it came from; a pack that is quietly missing the gate would +# reach Colab as a build that measures speed and proves nothing. $need = @( "scripts\mining-nvidia\colab_cuda_smoke.sh", + "scripts\mining-nvidia\colab_cuda_gate.sh", "scripts\mining-nvidia\COLAB-T4.md", + "scripts\x16rs_gate_trees.py", + "src\bin\x16rs_gate.rs", + "app\src\x16rs_gate.rs", + "x16rs-cuda\build.rs", + "x16rs-cuda\cuda\block_miner.cu", + "x16rs\opencl\x16rs.cl", "x16rs-cuda\Cargo.toml", "Cargo.toml" ) @@ -104,6 +113,50 @@ foreach ($rel in $need) { } } +# The same content checks the Colab notebook makes on a clone, made here instead, +# because a zip has no commit to check. +$content = @{ + "app\src\x16rs_gate.rs" = "CudaBackend" + "src\bin\x16rs_gate.rs" = "--backend" + "x16rs-cuda\build.rs" = "X16RS_CUDA_KERNEL_DIR" + "x16rs\opencl\x16rs.cl" = "X16RS_H_BLAKE_INIT" +} +foreach ($rel in $content.Keys) { + $needle = $content[$rel] + if (-not (Select-String -Path (Join-Path $Stage $rel) -SimpleMatch -Pattern $needle -Quiet)) { + throw "This tree predates the CUDA gate: $rel does not contain '$needle'. Packing it would ship a Colab run that cannot prove anything." + } +} + +# Stamp the pack so a zip on Colab can still say where it came from. The zip +# carries no .git, so this file is the only thing that identifies the source. +# +# Each value is captured into its own variable first. Calling a native command +# inside an array literal makes PowerShell fold the whole literal into one +# space-joined string, which produced a one-line stamp that looked fine in the +# console and was unreadable as key=value. +$packedUtc = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ") +$commit = (& git -C $Root rev-parse HEAD) | Select-Object -First 1 +$branch = (& git -C $Root rev-parse --abbrev-ref HEAD) | Select-Object -First 1 +$porcelain = & git -C $Root status --porcelain +$dirty = if ($porcelain) { "true" } else { "false" } +if (-not $commit) { $commit = "unknown" } +if (-not $branch) { $branch = "unknown" } + +$stampLines = @( + "packed_utc=$packedUtc", + "commit=$commit", + "branch=$branch", + "dirty=$dirty" +) +Set-Content -Path (Join-Path $Stage "COLAB-PACK-STAMP.txt") -Value $stampLines -Encoding utf8 +Write-Host "Pack stamp:" +foreach ($line in $stampLines) { Write-Host " $line" } +if ($dirty -eq "true") { + Write-Host " NOTE: the working tree has uncommitted changes, so this zip is NOT commit $commit." + Write-Host " It is that commit plus whatever is currently uncommitted. Say so in the log." +} + if (Test-Path $Zip) { Remove-Item -Force $Zip } Write-Host "Compressing..." Compress-Archive -Path $Stage -DestinationPath $Zip -CompressionLevel Optimal @@ -118,4 +171,8 @@ Write-Host " 1) Runtime -> T4 GPU" Write-Host " 2) Upload this zip" Write-Host " 3) !unzip -q hacash-fullnodedev-colab-slim.zip -d /content" Write-Host " 4) %cd /content/hacash-fullnodedev" -Write-Host " 5) !bash scripts/mining-nvidia/colab_cuda_smoke.sh" +Write-Host " 5) !bash scripts/mining-nvidia/colab_cuda_gate.sh # correctness FIRST" +Write-Host " 6) !bash scripts/mining-nvidia/colab_cuda_smoke.sh # then the crate tests" +Write-Host "" +Write-Host "This zip has no .git, so the gate log will say commit=not-a-git-checkout." +Write-Host "COLAB-PACK-STAMP.txt inside the zip carries the commit instead. Keep them together." diff --git a/scripts/mining-nvidia/poworker.cuda.ini.example b/scripts/mining-nvidia/poworker.cuda.ini.example index fb0fa3df..be6635a0 100644 --- a/scripts/mining-nvidia/poworker.cuda.ini.example +++ b/scripts/mining-nvidia/poworker.cuda.ini.example @@ -1,6 +1,15 @@ -; HAC block miner — NVIDIA CUDA (RTX 20xx / 30xx / 40xx) +; HAC block miner: NVIDIA CUDA (RTX 20xx / 30xx / 40xx) ; Copy via INSTALL-CUDA-CONFIG.bat or edit target\release\poworker.config.ini +; Your own fullnode, or a pool. Three forms are accepted: +; host:port plain HTTP, for a node on this machine or your LAN +; http://host:port the same, spelled out +; https://pool.example TLS, and what you want for any pool that is not yours +; +; Plain HTTP to somebody else's pool is not just unencrypted: a pool credits a +; share to whatever payout address the request names, so anyone on the path can +; resend your work under their address and be paid for it. The miner says so at +; startup if you point it off this machine without TLS. connect = 127.0.0.1:8080 supervene = 4 nonce_max = 4294967295 @@ -12,9 +21,13 @@ power_cost_kwh = 0.15 gpu_watts = 0 cpu_watts_per_thread = 8 hac_price = 0 +; dynamic_supervene does nothing unless supervene_max > 0: spawn_supervene +; refuses to rebalance without a ceiling to rebalance towards. This said true +; next to a 0 cap, so the CPU assist never moved. The cap is the configured +; thread count above, which is what the flag was always meant to reach. dynamic_supervene = true supervene_min = 2 -supervene_max = 0 +supervene_max = 4 oom_fallback = true max_temp_c = 0 throttle_work_groups = 1024 @@ -28,8 +41,13 @@ benchmark_seconds = 0 use_cuda = true use_opencl = false cuda_device = 0 +; cpu_assist assumes poworker is the ONLY miner on this box. The diamond +; worker's automatic thread count already takes every logical CPU but two, and +; those two are the fullnode's and this card's feed thread. Running both with +; cpu_assist on spends them twice: set this false, or lower diaworker's +; supervene by these threads. cpu_assist = true -; RTX starting point — tune work_groups / unit_size after genesis test +; RTX starting point: tune work_groups / unit_size after genesis test work_groups = 131072 local_size = 256 unit_size = 8 diff --git a/scripts/mining-nvidia/tune_cuda.py b/scripts/mining-nvidia/tune_cuda.py new file mode 100644 index 00000000..bd7014d5 --- /dev/null +++ b/scripts/mining-nvidia/tune_cuda.py @@ -0,0 +1,706 @@ +#!/usr/bin/env python3 +# Extracted verbatim from the Cell 5b block in colab_cuda_pool_e2e.md so it can +# be run as a file instead of pasted. 643 lines is too many to copy into a +# notebook reliably, and a truncated paste would fail in ways that look like a +# tuner fault rather than a copy fault. +# +# python3 scripts/mining-nvidia/tune_cuda.py +# +# The doc section around that block explains what PASS and FAIL look like and +# what has never run on an NVIDIA device. + +# =========================================================================== +# Cell 5b: tune this card with the REAL tuner, and prove the tune was worth it +# =========================================================================== +# +# WHAT RUNS. app/src/autotune16.rs, reached the way an operator reaches it: +# [efficiency] benchmark_seconds > 0 in a poworker config with [gpu] use_cuda = +# true. poworker::run_cuda_benchmark builds the TuneRequest, the tuner probes the +# card, plans a shared corpus, proves EVERY candidate against x16rs::block_hash +# over its whole launch window, sweeps, refines, soaks until the card stops +# moving, and patches the ini it was given. Nothing here re-implements a sweep, a +# score, a proof or a pick. What this cell adds is the one thing the tuner cannot +# do for itself: an independent fixed-work measurement of the shape it chose +# against the shape it started from, and a refusal to let a tune that failed its +# own proofs reach the config the miner runs. +# +# TWO EXIT-CODE TRAPS, both already walked into on this project: +# +# * %%bash swallows exit codes, and so does `!cmd`: IPython ignores the status, +# so the next cell runs on whatever the last successful run left behind. +# Every process below runs under subprocess and its exit code is PRINTED on +# its own line and then read. +# * poworker exits 0 EVEN WHEN THE TUNE WAS REFUSED. run_block_mining_benchmark +# returns, poworker() returns, main() returns, status 0. "[autotune] +# REJECTED", "the card never settled" and a clean win all exit 0 alike. The +# exit code here is necessary and never sufficient: the verdict is parsed out +# of the report, and this cell says which of the two it is reading. +# +# THE COST TRAP. A warmup measured in BATCHES rather than seconds cost this +# project 37 minutes of blank screen once. So: the estimate is printed BEFORE any +# work starts, the tuner's own estimate is echoed and projected the moment it +# appears, a heartbeat prints during silence, and a hard timeout kills the run +# rather than letting it eat the session. +# +# WHERE THE TIME GOES, and it is not where you would guess. On a free 2-vCPU +# Colab VM the CPU oracle runs on ONE thread (autotune_oracle_threads is +# available_parallelism() - 2, floored at 1) and it CPU-hashes every candidate's +# entire launch window at repeat 16. That is minutes per candidate and it dwarfs +# the GPU sweep. It is also the thing being bought: a shape whose hashes were +# never proved equal to the CPU's is a number, not a result. + +import math, os, queue, re, shutil, subprocess, sys, threading, time + +# ------------------------------------------------------------------ knobs -- +D = "/content/fullnodedev" +REL = os.path.join(D, "target", "release") +TUNE_DIR = "/content/tune" # the tuner's own config +MINER_CONFIG = os.path.join(REL, "poworker.config.ini") # what Cell 7 mines with +CUDA_DEVICE = 0 +SIZE = "default" # "fast" | "default" | "full", see SIZES below +MODE = "max" # "max" ranks sustained hashrate, "eco" ranks kH/J +PRESET = "nvidia_balanced" # the shipped shape the tune is judged against +TIMEOUT_MIN = 75 # hard kill on the tune. Nothing may outlive the session. +INSTALL = True # copy a PASSING tune into MINER_CONFIG + +# The three sizes differ in ONE thing: [gpu] work_groups, which is the ceiling of +# the tuner's work-group axis (poworker.rs: max_wg = memory_wg.min(work_groups)). +# The floor is the card's multiprocessor count, so on a 40-SM T4 the axis is +# 48..cap on the dyadic grid, the coarse sweep takes the powers-of-two family of +# it, and the unit-size axis is 32/64/128 whatever the cap is. +# +# grid_nonces is the sum of those coarse candidates' launch windows on a 40-SM +# card, which is what the CPU oracle has to hash. It is an UPPER BOUND: the +# latency prune and the shared corpus only ever remove shapes. +SIZES = { + # wg ceiling benchmark_seconds sum of candidate windows + "fast": {"cap": 256, "seconds": 180, "grid_nonces": 25.7e6}, + "default": {"cap": 512, "seconds": 240, "grid_nonces": 55.1e6}, + "full": {"cap": 768, "seconds": 360, "grid_nonces": 73.9e6}, +} + +# Constants the estimate is arithmetic on, each with its source. +T4_MHS = 7.54e6 # measured on a real T4, repeat 16, at 256x256x64 +ORACLE_HPS_CORE = 60_000.0 # x16rs_gate::CPU_ORACLE_HPS_PER_CORE +PROOF_LAUNCHES = 33 # per candidate: 1 all-ones count, 31 rank thresholds, + # 1 best-hash reduction, each reading the whole window +SPREAD_PCT = 2.6 # x16rs_gate::BETWEEN_PROCESS_SPREAD_PCT +BASELINE_RUNS = 7 +BASELINE_WARMUP = 4 # BATCHES, not seconds. Printed in both units below. +BASELINE_TARGET = 25e6 # nonces per baseline run, about 3.3 s on a T4 +BASELINE_BUDGET = 20 * 60 # seconds allowed for both baselines together + + +def die(msg): + """Stop the notebook loudly. A nonzero exit does not stop a Colab notebook, + so everything here raises rather than trusting a status nobody reads.""" + lines = msg.strip().splitlines() + print("\n" + "#" * 72) + print("# STOP") + for line in lines: + print("# " + line) + print("#" * 72) + sys.stdout.flush() + raise RuntimeError(lines[0]) + + +def hhmm(seconds): + seconds = int(max(0, seconds)) + return "%02d:%02d" % (seconds // 60, seconds % 60) + + +def wrap(text, width=66): + out, line = [], "" + for word in text.split(): + if len(line) + len(word) + 1 > width: + out.append(line) + line = word + else: + line = (line + " " + word).strip() + if line: + out.append(line) + return out + + +# --------------------------------------------------------------- preflight -- +if shutil.which("nvidia-smi") is None: + die("""No nvidia-smi. This runtime has no NVIDIA GPU, so there is no card to +tune and every number below would be a measurement of nothing. +Runtime -> Change runtime type -> T4 GPU.""") + +POWORKER = os.path.join(REL, "poworker") +GATE = os.path.join(REL, "x16rs_gate") +for path, cell in ((POWORKER, "Cell 4"), (GATE, "Cell 2")): + if not os.path.exists(path): + die("%s is missing. Run %s first." % (path, cell)) +if SIZE not in SIZES: + die("SIZE must be one of: %s" % ", ".join(sorted(SIZES))) +size = SIZES[SIZE] + +gpu_name = subprocess.run( + ["nvidia-smi", "--query-gpu=name,power.limit,memory.total", + "--format=csv,noheader"], capture_output=True, text=True).stdout.strip() +cpus = os.cpu_count() or 2 +oracle_threads = max(1, cpus - 2) # autotune_oracle_threads(), poworker.rs +is_t4 = "T4" in gpu_name.upper() + +print("card :", gpu_name) +print("vCPUs : %d, so the tuner's CPU oracle gets %d thread(s)" + % (cpus, oracle_threads)) + +# --------------------------------------------------- what this will cost ---- +# All arithmetic on the two constants above, none of it measured on YOUR card. +# The tuner prints its own estimate within the first minute and that one IS +# measured; this is here so nobody stares at a blank cell until then. +est_oracle = size["grid_nonces"] / (ORACLE_HPS_CORE * oracle_threads) +est_launch = size["grid_nonces"] * PROOF_LAUNCHES / T4_MHS +est_sweep = 0.8 * size["seconds"] # SWEEP_BUDGET_SHARE +est_soak = min(900.0, max(90.0, size["seconds"] / 2.0)) # soak_cap_seconds +est_refine = 0.4 * (est_oracle + est_launch) # up to 8 neighbours +est_final = 300.0 # 255-threshold proof +est_total = est_oracle + est_launch + est_sweep + est_soak + est_refine + est_final + +def refuse_if_the_card_is_busy(): + """Refuse to start if something else is already measuring on this GPU. + + Run this cell twice by accident and two tuners share one card and one oracle + core. It does not produce wrong numbers, because each still proves its own + candidates against the CPU, but it produces MEANINGLESS ones, and they look + exactly like real ones. Measured on a T4 the moment it happened: + + one tuner 64x256x32 6.22 MH/s p95 86ms + two tuners 64x256x32 3.66 MH/s p95 190ms + + Half the rate and twice the latency, and nothing on screen says why. The + honest thing a measuring tool can do is decline. + """ + try: + out = subprocess.run(["ps", "-eo", "pid,args"], capture_output=True, + text=True, timeout=10).stdout + except Exception: + return # no ps: better to run than to refuse for a missing tool + mine = os.getpid() + busy = [] + for line in out.splitlines()[1:]: + line = line.strip() + if not line: + continue + pid_text, _, args = line.partition(" ") + try: + pid = int(pid_text) + except ValueError: + continue + if pid == mine: + continue + if "release/poworker" in args or "release/x16rs_gate" in args: + busy.append((pid, args.strip()[:90])) + if not busy: + return + print("") + print("#" * 72) + print("# STOP: something else is already on this GPU.") + for pid, args in busy: + print("# pid %-8s %s" % (pid, args)) + print("#") + print("# Two measurements on one card time each other's contention, and the") + print("# numbers look normal. Stop the other run, or wait for it, then start") + print("# this one again:") + print("# !pkill -f 'release/poworker'") + print("#" * 72) + sys.exit(2) + + +refuse_if_the_card_is_busy() + +print("") +print("SIZE = %s: [gpu] work_groups = %d, [efficiency] benchmark_seconds = %d" + % (SIZE, size["cap"], size["seconds"])) +print("estimate, at the 7.54 MH/s measured on a T4 and %d kH/s per oracle core:" + % (ORACLE_HPS_CORE / 1000)) +for label, value in (("CPU oracle, proves every candidate", est_oracle), + ("proof launches on the card", est_launch), + ("timed sweep passes", est_sweep), + ("refinement allowance", est_refine), + ("soak, at most", est_soak), + ("final 255-threshold proof, allowance", est_final)): + print(" %-38s %5.0f s" % (label, value)) +print(" %-38s %5.0f s (about %d min)" + % ("TOTAL, upper bound", est_total, round(est_total / 60))) +print("hard timeout : %d min" % TIMEOUT_MIN) +if not is_t4: + print("NOTE: this is not a T4. That estimate is grid arithmetic for a 40-") + print(" multiprocessor card at 7.54 MH/s and does not transfer. Read the") + print(" tuner's own '[autotune] estimated total' line instead.") +if MODE != "max": + print("NOTE: MODE = %s, so the tuner ranks candidates on something other than" + % MODE) + print(" throughput. The fixed-work check at the end is a HASHRATE") + print(" comparison, so a tuned shape that trades hashrate for watts is") + print(" expected to lose it. Read the kH/J line above it.") +if est_total > TIMEOUT_MIN * 60: + die("""The estimate (%d min) is longer than TIMEOUT_MIN (%d min), so this would +be killed part way and prove nothing. Raise TIMEOUT_MIN, or set SIZE = "fast", +which searches 48..256 work groups instead of 48..%d.""" + % (round(est_total / 60), TIMEOUT_MIN, size["cap"])) +sys.stdout.flush() + + +# ---------------------------------------------------- streaming subprocess -- +def run_streaming(argv, cwd, deadline, label, watch=None): + """Run argv, stamp every line with elapsed time, print a heartbeat while the + child is quiet, kill it at `deadline`. `watch(line)` may return a string, + which kills the child and becomes the abort reason. + + Returns (exit_code, lines, abort_reason); exit_code is None if it was killed. + """ + print("\n>>> %s" % label) + print(">>> " + " ".join(argv)) + sys.stdout.flush() + proc = subprocess.Popen(argv, cwd=cwd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) + lines, abort, q = [], None, queue.Queue() + + def reader(): + for line in proc.stdout: + q.put(line.rstrip("\n")) + q.put(None) + + threading.Thread(target=reader, daemon=True).start() + started = last_seen = time.time() + last_line = "" + while True: + try: + line = q.get(timeout=5) + except queue.Empty: + line = "" + if line is None: + break + if line != "": + lines.append(line) + last_seen, last_line = time.time(), line + print("[+%s] %s" % (hhmm(time.time() - started), line)) + sys.stdout.flush() + if watch is not None: + abort = watch(line) + if abort: + break + elif time.time() - last_seen > 45: + print("[+%s] ... still running, %ds since the last line. The CPU oracle" + " is silent while it hashes. Last line: %s" + % (hhmm(time.time() - started), int(time.time() - last_seen), + last_line[:80])) + sys.stdout.flush() + last_seen = time.time() + if time.time() > deadline: + abort = "the hard timeout" + break + if abort: + print("\n[+%s] KILLING %s: %s" % (hhmm(time.time() - started), label, abort)) + proc.terminate() + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + proc.kill() + print("exit code (%s): killed, no status" % label) + sys.stdout.flush() + return None, lines, abort + rc = proc.wait() + print("[+%s] %s finished" % (hhmm(time.time() - started), label)) + print("exit code (%s): %d" % (label, rc)) + sys.stdout.flush() + return rc, lines, None + + +# --------------------------------- what shape does the SHIPPED preset give? -- +# Read out of the binary rather than copied out of nvidia_launch.rs, so this +# cannot quote a ladder the build does not contain. A config with gpu_profile set +# and NO work_groups / unit_size keys makes resolve_gpu_tuning fall back to the +# preset, and PoWorkConf::new prints what it resolved before anything else runs. +os.makedirs(os.path.join(TUNE_DIR, "preset"), exist_ok=True) +preset_cfg = os.path.join(TUNE_DIR, "preset", "poworker.config.ini") +open(preset_cfg, "w").write("""connect = 127.0.0.1:1 +supervene = 0 + +[gpu] +use_opencl = false +use_cuda = false +gpu_profile = %s + +[efficiency] +mode = %s +benchmark_seconds = 0 +""" % (PRESET, MODE)) + +print("\n>>> asking this build what %s resolves to" % PRESET) +sys.stdout.flush() +proc = subprocess.Popen([POWORKER, preset_cfg], cwd=REL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) +killer = threading.Timer(60, proc.kill) # it must not be able to hang the cell +killer.start() +RE_EFF = re.compile( + r"\[efficiency\] mode=(\S+) profile=(\S+) work_groups=(\d+) unit_size=(\d+)") +preset_line = None +try: + for line in proc.stdout: + line = line.rstrip("\n") + print(" ", line) + preset_line = RE_EFF.search(line) + if preset_line: + break +finally: + killer.cancel() + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() +if preset_line is None: + die("""poworker never printed its [efficiency] line, so the shipped preset for +%s could not be read out of this build, and there is nothing to judge a tune +against.""" % PRESET) +preset_shape = (int(preset_line.group(3)), int(preset_line.group(4))) +print("shipped preset : %s = work_groups %d, unit_size %d, local_size 256" + % (PRESET, preset_shape[0], preset_shape[1])) +if preset_shape[0] > size["cap"]: + print("NOTE: the preset's %d work groups is ABOVE this SIZE's %d ceiling, so" + % (preset_shape[0], size["cap"])) + print(" the tuner cannot reach the preset's own shape. The comparison at") + print(" the end is still valid, it just is not a search that contains it.") +sys.stdout.flush() + + +# ------------------------------------------------------- the tuner's config -- +# Every key the tune writes back MUST already be present: apply_benchmark_pick +# REPLACES keys, it does not add them, so a missing unit_size line would mean a +# tune that silently keeps the old value. gpu_profile, work_groups, unit_size and +# benchmark_seconds are all here for that reason. +# +# supervene = 0 means no CPU assist threads, so Economics::cpu_watts is 0 and the +# watts in the report are the card's, straight from nvidia-smi. +os.makedirs(TUNE_DIR, exist_ok=True) +tune_cfg = os.path.join(TUNE_DIR, "poworker.config.ini") +open(tune_cfg, "w").write("""connect = 127.0.0.1:18082 +supervene = 0 +nonce_max = 4294967295 +notice_wait = 3 + +[gpu] +use_opencl = false +use_cuda = true +cuda_device = %d +gpu_profile = %s +work_groups = %d +local_size = 256 +unit_size = 64 + +[efficiency] +mode = %s +benchmark_seconds = %d +dynamic_supervene = false +oom_fallback = true +max_temp_c = 0 +pause_if_unprofitable = false +power_cost_kwh = 0 +hac_price = 0 +stats_file = tune-stats.json +""" % (CUDA_DEVICE, PRESET, size["cap"], MODE, size["seconds"])) +print("\ntune config :", tune_cfg) +print("miner config : %s %s" % (MINER_CONFIG, + "" if os.path.exists(MINER_CONFIG) else "(MISSING: Cell 5 writes it)")) +print("no node needed : the tune finishes and returns before poworker ever") +print(" contacts `connect`.") +sys.stdout.flush() + + +# ------------------------------------------------------------- run the tune -- +RE_ESTIMATE = re.compile(r"estimated total before the soak: about (\d+)s") +started_at = time.time() +deadline = started_at + TIMEOUT_MIN * 60 + + +def watch(line): + """Early abort. The tuner's own estimate covers the timed sweep passes and + the CPU oracle. It does NOT cover the 33 proof launches per candidate, the + refinement, the soak or the 255-threshold final proof, so this projects it by + 1.5 and adds the soak cap and the final-proof allowance. Better to stop in + the first minute than to be killed 60 minutes in with nothing to show.""" + m = RE_ESTIMATE.search(line) + if not m: + return None + tuner_est = float(m.group(1)) + need = tuner_est * 1.5 + est_soak + est_final + left = deadline - time.time() + print(" >>> the tuner's OWN estimate is %ds. Projected through the final" + " proof: %d min. Left before the hard timeout: %d min." + % (tuner_est, round(need / 60), round(left / 60))) + sys.stdout.flush() + if need > left: + return ("this tune projects to %d more minutes and only %d remain. Nothing" + " has been wasted yet: set SIZE = \"fast\", or raise TIMEOUT_MIN." + % (round(need / 60), round(left / 60))) + return None + + +rc, log, abort = run_streaming( + [POWORKER, tune_cfg], REL, deadline, + "the tuner (poworker, benchmark_seconds=%d)" % size["seconds"], watch=watch) +text = "\n".join(log) + +# --------------------------------------------------------------- read it ---- +UNITS = {"H/s": 1.0, "kH/s": 1e3, "MH/s": 1e6} +chosen = re.search( + r"chosen shape\s*:\s*work_groups=(\d+) local_size=(\d+) unit_size=(\d+)", text) +sust = re.search( + r"sustained\s*:\s*([\d.]+) (MH/s|kH/s|H/s) raw, ([\d.]+) (MH/s|kH/s|H/s)", text) +lat = re.search(r"batch latency\s*:\s*p50 (\d+) ms, p95 (\d+) ms", text) +power = re.search(r"board power\s*:\s*([\d.]+) W (measured|estimated)", text) +soak = re.search( + r"soak\s*:\s*(\d+) passes over (\d+)s, (settled|DID NOT SETTLE[^\n]*)", text) +applied = re.search( + r"\[benchmark\] Applied gpu_profile=(\S+) \(work_groups=(\d+), unit_size=(\d+)\)", + text) +rejects = [l for l in log if "REJECTED" in l and "[autotune] REJECTED:" not in l] +proof_bad = [l for l in rejects if "failed the equivalence proof" in l] +refused = [l for l in log if "[autotune] REJECTED:" in l] + +fail = [] +if abort: + fail.append("the run was killed: %s." % abort) +elif rc != 0: + fail.append("poworker exited %s. It exits 0 even when a tune is refused, so a" + " nonzero status is something else: a panic, or the VM's OOM" + " killer." % rc) +if refused: + fail.append("the tuner refused the whole session. %s" % refused[0]) +if proof_bad: + fail.append("%d candidate(s) FAILED THE CPU ORACLE, or errored inside the" + " proof that runs it. A shape whose hashes were not proved equal" + " to x16rs::block_hash must not reach a mining config, so nothing" + " was installed. The first one: %s" % (len(proof_bad), proof_bad[0])) +elif rejects: + fail.append("%d candidate(s) were rejected for reasons other than the proof." + " A healthy tune rejects none, so this cell will not install a" + " config over it." % len(rejects)) +if chosen is None or sust is None: + fail.append("no report block was printed, so no shape was chosen.") +if soak is not None and not soak.group(3).startswith("settled"): + fail.append("the soak did not settle, so the shape is not proven to sustain:" + " %s" % soak.group(3)) +if applied is None and not fail: + fail.append("the tuner never printed '[benchmark] Applied ...', so it reported" + " a winner and did not patch its own config.") + +print("\n" + "=" * 72) +print(" THE TUNE") +print("=" * 72) +for line in rejects + refused: + print(" rejection:", line) +shape = raw = valid = None +if chosen and sust: + shape = (int(chosen.group(1)), int(chosen.group(3))) + raw = float(sust.group(1)) * UNITS[sust.group(2)] + valid = float(sust.group(3)) * UNITS[sust.group(4)] + print(" chosen shape : work_groups=%d local_size=256 unit_size=%d" + % (shape[0], shape[1])) + print(" hashrate : %.2f MH/s raw, %.2f MH/s after the stale work a" + " template change throws away" % (raw / 1e6, valid / 1e6)) + if lat: + print(" batch latency : p50 %s ms, p95 %s ms, against the 1500 ms ceiling" + % (lat.group(1), lat.group(2))) + if power and power.group(2) == "measured": + watts = float(power.group(1)) + print(" board power : %.0f W, measured by nvidia-smi" % watts) + print(" efficiency : %.1f kH/J raw, %.1f kH/J after stale work." + % (raw / watts / 1e3, valid / watts / 1e3)) + print(" Card only: supervene = 0 here, so the tuner's") + print(" cpu_watts term is 0 and this is the whole draw") + print(" it scored on.") + else: + print(" board power : NOT MEASURED, so there is no kH/J. On NVIDIA") + print(" that means nvidia-smi did not report power.draw,") + print(" and an eco tune would have ranked every shape on") + print(" one constant, which is max mode by another name.") + if soak: + print(" soak : %s passes over %ss, %s" + % (soak.group(1), soak.group(2), soak.group(3))) +else: + print(" no report. The last lines the tuner printed were:") + for line in log[-15:]: + print(" " + line) +sys.stdout.flush() + + +# ------------------- an independent fixed-work check: tuned vs the preset ---- +# Two `x16rs_gate baseline` runs: the same kernel, the same height (repeat 16) and +# the same fixed corpus the tuner used, measured by a DIFFERENT binary in a +# different process, so the tune does not mark its own homework. +# +# Identical work on both sides on purpose: --headers 1 pins both shapes to one +# intro, and the batch counts are chosen so both hash exactly the same nonce +# range. What is left is the between-process spread, about 2.6% on this kernel, +# and that is the bar a claimed gain has to clear. +compare = None +if shape and not fail: + if shape == preset_shape: + print("\nThe tuner chose the shipped preset's own shape, so there is nothing") + print("to compare: the baseline would be the same shape twice.") + else: + per_w = shape[0] * 256 * shape[1] + per_p = preset_shape[0] * 256 * preset_shape[1] + block = (per_w * per_p) // math.gcd(per_w, per_p) # identical work needs + total = block * max(1, math.ceil(BASELINE_TARGET / block)) # a common multiple + rate = raw if raw and raw > 0 else T4_MHS + runs = BASELINE_RUNS + while runs > 3 and 2 * runs * total / rate > BASELINE_BUDGET / 2: + runs -= 1 + print("\nfixed-work check: %d runs of %d nonces on EACH shape, the same" + % (runs, total)) + print("nonces and the same single header on both sides, about %.1f s a run" + % (total / rate)) + print("at the tuned shape's own %.2f MH/s." % (rate / 1e6)) + print("warmup is %d BATCHES, once, before any timed run: about %.1f s for" + % (BASELINE_WARMUP, BASELINE_WARMUP * per_w / rate)) + print("the tuned shape and %.1f s for the preset. Both sides together:" + % (BASELINE_WARMUP * per_p / rate)) + print("about %d min." % max(1, round(2 * runs * total / rate / 60 + 1))) + sys.stdout.flush() + bl_deadline = time.time() + BASELINE_BUDGET + + def baseline(what, wg, us): + rc2, out, ab = run_streaming( + [GATE, "baseline", "--backend", "cuda", + "--cuda-device", str(CUDA_DEVICE), + "--work-groups", str(wg), "--local-size", "256", + "--unit-size", str(us), "--headers", "1", + "--batches", str(total // (wg * 256 * us)), "--runs", str(runs), + "--warmup", str(BASELINE_WARMUP)], + REL, bl_deadline, "baseline %s (%dx256x%d)" % (what, wg, us)) + if ab or rc2 != 0: + return None, None + body = "\n".join(out) + med = re.search(r"median\s*:\s*([\d.]+) (MH/s|kH/s|H/s)", body) + spr = re.search(r"peak-to-peak ([\d.]+)%", body) + if med is None: + return None, None + return (float(med.group(1)) * UNITS[med.group(2)], + float(spr.group(1)) if spr else None) + + tuned_hps, tuned_spread = baseline("tuned", shape[0], shape[1]) + preset_hps, preset_spread = baseline("preset", preset_shape[0], preset_shape[1]) + if tuned_hps is None or preset_hps is None: + fail.append("the fixed-work baseline did not complete, so the tuned" + " shape was never compared with the shipped preset by" + " anything except the tuner itself.") + else: + delta = (tuned_hps - preset_hps) / preset_hps * 100.0 + compare = (tuned_hps, preset_hps, delta) + print("\n" + "=" * 72) + print(" TUNED vs SHIPPED PRESET: fixed work, separate processes") + print("=" * 72) + print(" tuned %5dx256x%-4d: %6.2f MH/s (its own runs spanned %s)" + % (shape[0], shape[1], tuned_hps / 1e6, + "%.2f%%" % tuned_spread if tuned_spread is not None else "?")) + print(" preset %5dx256x%-4d: %6.2f MH/s (its own runs spanned %s)" + % (preset_shape[0], preset_shape[1], preset_hps / 1e6, + "%.2f%%" % preset_spread if preset_spread is not None else "?")) + print(" difference : %+.2f%%, against the %.1f%% between-" + "process spread" % (delta, SPREAD_PCT)) + if abs(delta) < SPREAD_PCT: + print(" VERDICT : NO GAIN SHOWN. These two shapes measure") + print(" the same here. The tune is still worth") + print(" having, it PROVED the shape against the") + print(" CPU, but do not quote a speedup.") + elif delta > 0: + print(" VERDICT : the tuned shape BEATS the shipped preset") + print(" by %.2f%%, which clears the spread." % delta) + else: + print(" VERDICT : the tuned shape LOST to the preset by") + print(" %.2f%%, outside the spread. That is a" + % -delta) + print(" contradiction worth reporting, and no") + print(" config is installed over it.") + fail.append("the tuned shape measured %.2f%% SLOWER than the shipped" + " preset in an independent fixed-work run." % -delta) +sys.stdout.flush() + + +# ------------------------------------------------- install, or say why not --- +def patch_ini(path, wg, us, profile): + """Rewrite [gpu] work_groups / unit_size / gpu_profile, ADDING the keys when + they are absent. efficiency.rs apply_benchmark_pick only replaces keys that + already exist, which is right for the tuner's own config (written above with + all of them) and not enough for a config written by Cell 5.""" + want = {"gpu_profile": str(profile), "work_groups": str(wg), "unit_size": str(us)} + out, in_gpu, seen, gpu_at = [], False, set(), None + for line in open(path).read().splitlines(): + t = line.strip() + if t.startswith("["): + in_gpu = t.lower() == "[gpu]" + if in_gpu: + gpu_at = len(out) + 1 # the line just after the header + elif in_gpu and "=" in t and not t.startswith(("#", ";")): + key = t.split("=", 1)[0].strip() + if key in want: + seen.add(key) + line = "%s = %s" % (key, want[key]) + out.append(line) + missing = ["%s = %s" % (k, v) for k, v in want.items() if k not in seen] + if gpu_at is None: + out += ["", "[gpu]"] + missing + else: + out[gpu_at:gpu_at] = missing + open(path, "w").write("\n".join(out) + "\n") + + +print("\n" + "#" * 72) +if fail: + print("# RESULT: FAIL. Nothing was written to the miner's config.") + for reason in fail: + print("#") + for line in wrap(reason): + print("# " + line) + print("#") + for line in wrap("The tuner may still have patched its OWN config at %s. That" + " file is not what Cell 7 mines with, and this cell copied" + " nothing out of it." % tune_cfg): + print("# " + line) + print("#" * 72) + print("total wall time : %s" % hhmm(time.time() - started_at)) + raise RuntimeError(fail[0]) + +print("# RESULT: PASS") +print("# shape %dx256x%d, proved against the CPU over its whole %d-nonce window," + % (shape[0], shape[1], shape[0] * 256 * shape[1])) +print("# settled under soak, and %s" + % ("measured %+.2f%% against the shipped preset." % compare[2] if compare + else "identical to the shipped preset.")) +print("#" * 72) +print("the tuner patched its own config: %s -> gpu_profile=%s work_groups=%s" + " unit_size=%s" % (tune_cfg, applied.group(1), applied.group(2), + applied.group(3))) +if (int(applied.group(2)), int(applied.group(3))) != shape: + die("""The shape in the report and the shape written to the ini disagree. +The report says %dx%d, the ini was given %sx%s. Do not mine on either until that +is understood.""" % (shape[0], shape[1], applied.group(2), applied.group(3))) +# And read it back off the disk, because "Applied" is a log line and the file is +# the thing. apply_benchmark_pick REPLACES keys and never adds them, so a config +# missing a key would print exactly this line and change nothing. +on_disk = dict(re.findall(r"^\s*(work_groups|unit_size)\s*=\s*(\d+)\s*$", + open(tune_cfg).read(), re.M)) +if (int(on_disk.get("work_groups", -1)), int(on_disk.get("unit_size", -1))) != shape: + die("""The tuner said it applied %dx%d but %s holds work_groups=%s +unit_size=%s. Nothing was installed.""" + % (shape[0], shape[1], tune_cfg, on_disk.get("work_groups"), + on_disk.get("unit_size"))) +if INSTALL and os.path.exists(MINER_CONFIG): + patch_ini(MINER_CONFIG, shape[0], shape[1], applied.group(1)) + print("installed into %s:" % MINER_CONFIG) + for line in open(MINER_CONFIG).read().splitlines(): + if line.strip().startswith(("work_groups", "unit_size", "gpu_profile")): + print(" " + line) +elif INSTALL: + print("MINER_CONFIG does not exist yet (Cell 5 writes it). Run this cell again") + print("after Cell 5, or set [gpu] work_groups = %d and unit_size = %d there by" + % (shape[0], shape[1])) + print("hand.") +print("total wall time : %s" % hhmm(time.time() - started_at)) diff --git a/scripts/pack-node-release-linux.sh b/scripts/pack-node-release-linux.sh new file mode 100644 index 00000000..e3aa43a6 --- /dev/null +++ b/scripts/pack-node-release-linux.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-manual}" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" +RELEASE="$ROOT/target/release" +OUT_DIR="$ROOT/dist-node" +PACKAGE_NAME="hpay-compatible-hacash-fullnode-linux-x86_64" +STAGE="$OUT_DIR/$PACKAGE_NAME" +ARCHIVE="$OUT_DIR/$PACKAGE_NAME-$VERSION.tar.gz" + +case "$(uname -m)" in + x86_64|amd64) ;; + *) echo "Unsupported release architecture: $(uname -m)"; exit 1 ;; +esac + +for required in \ + "$RELEASE/hacash" \ + "$ROOT/mainnet-configs/hacash.config.mainnet.ini" \ + "$ROOT/README-NODE.txt"; do + [[ -f "$required" ]] || { echo "Missing required node release file: $required"; exit 1; } +done + +command -v sha256sum >/dev/null 2>&1 || { + echo "sha256sum is required to create verifiable release archives" + exit 1 +} + +SOURCE_COMMIT="$(git -C "$ROOT" rev-parse HEAD)" +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "Unable to record an exact 40-character source commit" + exit 1 +} + +rm -rf -- "$STAGE" +rm -f -- "$ARCHIVE" "$ARCHIVE.sha256" +mkdir -p "$STAGE" + +cp -f "$RELEASE/hacash" "$STAGE/hacash" +cp -f "$ROOT/mainnet-configs/hacash.config.mainnet.ini" \ + "$STAGE/hacash.config.ini.example" +cp -f "$ROOT/README-NODE.txt" "$STAGE/README.txt" +printf '%s' "$VERSION" > "$STAGE/VERSION.txt" +printf '%s' "$SOURCE_COMMIT" > "$STAGE/SOURCE-COMMIT.txt" +chmod u+x "$STAGE/hacash" + +for forbidden in \ + poworker diaworker miner-panel hac-pool \ + hbit-pool-server hbit-pool-payout; do + [[ ! -e "$STAGE/$forbidden" ]] || { + echo "Standalone node package must not contain $forbidden" + exit 1 + } +done + +tar -czf "$ARCHIVE" -C "$OUT_DIR" "$PACKAGE_NAME" +(cd "$OUT_DIR" && sha256sum "$(basename "$ARCHIVE")" > "$(basename "$ARCHIVE").sha256") +echo "Packaged standalone node: $ARCHIVE" + diff --git a/scripts/pack-node-release.ps1 b/scripts/pack-node-release.ps1 new file mode 100644 index 00000000..fca35a84 --- /dev/null +++ b/scripts/pack-node-release.ps1 @@ -0,0 +1,60 @@ +param( + [string]$Version = "manual", + [string]$OutDir = "dist-node" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$Release = Join-Path $Root "target\release" +$Binary = Join-Path $Release "hacash.exe" +$Config = Join-Path $Root "mainnet-configs\hacash.config.mainnet.ini" +$Readme = Join-Path $Root "README-NODE.txt" +$PackageName = "hpay-compatible-hacash-fullnode-windows-x64" +$Stage = Join-Path $OutDir $PackageName + +foreach ($required in @($Binary, $Config, $Readme)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Missing required node release file: $required" + } +} + +$sourceCommit = (& git -C $Root rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or $sourceCommit -notmatch '^[0-9a-f]{40}$') { + throw "Unable to record an exact 40-character source commit" +} + +if (Test-Path -LiteralPath $Stage) { + Remove-Item -LiteralPath $Stage -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $Stage | Out-Null + +Copy-Item -LiteralPath $Binary -Destination (Join-Path $Stage "hacash.exe") +Copy-Item -LiteralPath $Config -Destination (Join-Path $Stage "hacash.config.ini.example") +Copy-Item -LiteralPath $Readme -Destination (Join-Path $Stage "README.txt") +Set-Content -LiteralPath (Join-Path $Stage "VERSION.txt") -Value $Version -NoNewline +Set-Content -LiteralPath (Join-Path $Stage "SOURCE-COMMIT.txt") -Value $sourceCommit -NoNewline + +$forbidden = @( + "poworker.exe", "diaworker.exe", "miner-panel.exe", "hac-pool.exe", + "hbit-pool-server.exe", "hbit-pool-payout.exe" +) +foreach ($name in $forbidden) { + if (Test-Path -LiteralPath (Join-Path $Stage $name)) { + throw "Standalone node package must not contain $name" + } +} + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$archive = Join-Path $OutDir "$PackageName-$Version.zip" +if (Test-Path -LiteralPath $archive) { + Remove-Item -LiteralPath $archive -Force +} +Compress-Archive -LiteralPath $Stage -DestinationPath $archive -CompressionLevel Optimal + +$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() +$checksum = "$hash $([IO.Path]::GetFileName($archive))$([Environment]::NewLine)" +$utf8NoBom = New-Object Text.UTF8Encoding($false) +[IO.File]::WriteAllText("$archive.sha256", $checksum, $utf8NoBom) + +Write-Host "Packaged standalone node: $archive" + diff --git a/scripts/x16rs_gate_trees.py b/scripts/x16rs_gate_trees.py new file mode 100644 index 00000000..5d9e054c --- /dev/null +++ b/scripts/x16rs_gate_trees.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Build the modified kernel trees the x16rs gate is exercised with. + +Every tree is a COPY of x16rs/opencl written somewhere else. The shipping tree is +never touched, and `app/src/opencl_gpu/compile.rs` fingerprints the full contents +of every .cl file, so each copy recompiles from source instead of picking up a +cached binary. + +Every tree here produces WRONG hashes on purpose. They are measuring and +self-testing instruments. Never point a miner at one. + + python scripts/x16rs_gate_trees.py x16rs/opencl [faults|forced|subst|all] + +faults three deliberate defects, used to prove the gate can fail: + faults/A shabal (algo 13) counter Wlow 1 -> 2 one algorithm, always wrong + faults/B the round's TRAILING barrier deleted a race, not one algorithm + faults/C one bit flipped in blake's IV a single-bit constant + +BOTH BACKENDS. These trees drive the CUDA gate as well as the OpenCL one, because +x16rs-cuda/cuda/block_miner.cu #includes util.cl, sha3_256.cl and x16rs.cl out of +the same directory. OpenCL takes the tree at runtime (`--opencl-dir`); CUDA +compiles at build time, so it takes it through an environment variable and a +rebuild: + + X16RS_CUDA_KERNEL_DIR=/faults/A \\ + cargo build --release --features cuda --bin x16rs_gate + x16rs_gate equiv --backend cuda # must exit 3 + +For that to mean anything every constant the kernels use has to live in these +.cl files and nowhere else. block_miner.cu used to carry its own copy of blake's +IV, which made faults/C compile to byte-identical CUDA PTX - a broken tree the +CUDA gate would have passed. `x16rs.cl` now exports X16RS_H_BLAKE_INIT and the +.cu reads it; `x16rs-cuda`'s test suite fails if the duplicate returns. Add a +fault that patches a constant, and check it actually changes both backends. + +forced forced/algoNN, NN = 00..15: every round runs algorithm NN. Isolates one + algorithm's cost, but the compiler drops the other fifteen branches, so + register pressure and occupancy are NOT production-like. + +subst subst/subNN: all sixteen branches stay, only `case NN:` calls shabal + instead of algorithm NN. Register pressure stays close to production. + subst/allshabal has every branch calling shabal, which pins the floor. + subst/sub13 is the null control: shabal substituted by shabal must + measure 1.000. +""" +import os +import re +import shutil +import sys + + +def copy_tree(src, dst): + if os.path.isdir(dst): + shutil.rmtree(dst) + os.makedirs(dst) + for name in os.listdir(src): + if name.endswith(".cl"): + shutil.copy(os.path.join(src, name), os.path.join(dst, name)) + + +def patch(dst, text): + open(os.path.join(dst, "x16rs.cl"), "w", encoding="utf-8").write(text) + + +def replace_once(text, old, new): + n = text.count(old) + assert n == 1, "expected 1 occurrence of %r, found %d" % (old[:60], n) + return text.replace(old, new) + + +ARM = re.compile( + r"( case (\d+): \\\n )" + r"hash_x16rs_func_\d+\(&\(local_hashes\)\[hash_pos\[0\]\][^;]*\);" +) +SHABAL_CALL = "hash_x16rs_func_13(&(local_hashes)[hash_pos[0]]);" + +SELECTORS = [ + (" unsigned char mod = (local_hashes)[(index) + h].h4[7] % 16; \\", + " unsigned char mod = %d; \\"), + (" unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \\", + " unsigned int mod = %d; \\"), + (" switch ((local_hashes)[hash_pos[0]].h4[7] % 16) { \\", + " switch (%d) { \\"), +] + +# The round's TRAILING barrier, the last statement of X16RS_RUN_REPEAT_LOOP's +# body. Deleting it leaves the round with no LOCAL|GLOBAL fence after the hash +# pass, which is the defect fault B exists to inject. +# +# This anchor is deliberately pinned to the macro terminator `\n }` rather +# than to the barrier text alone, because x16rs.cl contains a SECOND +# `barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE)` earlier in the same +# macro, between the scatter pass and the hash pass. Only the trailing one is +# followed by the end of the macro, so the match stays unique and +# `replace_once` keeps its assertion honest. +# +# It moved once already. Until v0.5.6 the barrier sat INSIDE the per-hash loop, +# and this anchor matched `} \ barrier \ } \ }` (switch close, barrier, hash +# loop close, macro close). When the barrier was hoisted out of the per-hash +# loop to the end of the round, that anchor stopped matching and this script +# died on the assertion in `replace_once` instead of quietly writing a tree with +# no fault in it. That loud failure is the intended behaviour: a fault tree that +# silently becomes a no-op turns the whole gate green over nothing. If you move +# the barrier again, this constant moves with it. +BARRIER = (" barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \\\n" + " }\n") + + +def build_faults(src, out, base): + a = replace_once(base, + " sph_u32 Wlow = 1, Whigh = 0;\n\n INPUT_BLOCK_ADD;", + " sph_u32 Wlow = 2, Whigh = 0;\n\n INPUT_BLOCK_ADD;") + b = replace_once(base, BARRIER, " }\n") + c = base.replace("SPH_C64(0x6A09E667F3BCC908)", + "SPH_C64(0x6A09E667F3BCC909)", 1) + assert c != base + for name, text in (("A", a), ("B", b), ("C", c)): + d = os.path.join(out, "faults", name) + copy_tree(src, d) + patch(d, text) + print("faults A (shabal Wlow), B (barrier removed), C (blake IV bit) ->", + os.path.join(out, "faults")) + + +def build_forced(src, out, base): + for old, _ in SELECTORS: + assert base.count(old) == 1, old[:60] + for algo in range(16): + d = os.path.join(out, "forced", "algo%02d" % algo) + copy_tree(src, d) + text = base + for old, new in SELECTORS: + text = text.replace(old, new % algo) + patch(d, text) + print("16 forced-algorithm trees ->", os.path.join(out, "forced")) + + +def build_subst(src, out, base): + assert len(ARM.findall(base)) == 16, "switch arms not found" + + def make(name, targets): + d = os.path.join(out, "subst", name) + copy_tree(src, d) + text, n = ARM.subn( + lambda m: m.group(1) + SHABAL_CALL + if int(m.group(2)) in targets else m.group(0), + base) + assert n == 16 + patch(d, text) + + for algo in range(16): + make("sub%02d" % algo, {algo}) + make("allshabal", set(range(16))) + print("16 substitution trees + allshabal ->", os.path.join(out, "subst")) + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + return 2 + src, out = sys.argv[1], sys.argv[2] + which = sys.argv[3] if len(sys.argv) > 3 else "all" + base = open(os.path.join(src, "x16rs.cl"), encoding="utf-8").read() + os.makedirs(out, exist_ok=True) + if which in ("faults", "all"): + build_faults(src, out, base) + if which in ("forced", "all"): + build_forced(src, out, base) + if which in ("subst", "all"): + build_subst(src, out, base) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/x16rs_sort_trees.py b/scripts/x16rs_sort_trees.py new file mode 100644 index 00000000..749b3551 --- /dev/null +++ b/scripts/x16rs_sort_trees.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Build kernel trees that vary ONLY the counting sort in X16RS_RUN_REPEAT_LOOP. + + python scripts/x16rs_sort_trees.py x16rs/opencl [name ...] + +Every tree is a COPY of x16rs/opencl written somewhere else; the shipping tree is +never touched. Unlike scripts/x16rs_gate_trees.py, the trees here are meant to be +CORRECT: the counting sort only decides which work item hashes which slot, and +every slot is still hashed exactly once by its own algorithm, so any valid +permutation gives byte-identical output. `x16rs_gate equiv` must pass on all of +them, and `x16rs_gate ab` reports a ratio that is not confounded by a wrong answer. + +Trees: + + stock verbatim copy. The A leg, and the null control when used as B. + + double the whole sort runs TWICE per repeat round, both times correctly. + The second permutation overwrites the first. This is a COST PROBE: + B/A - 1 is the wall-clock share of one complete counting sort + (its atomics, its serial scan and its three barriers). No change to + the sort can win more than that. + + privhist per work item private histogram. Pass one counts into 16 private + counters with no atomics at all; then ONE atomic_add per bucket per + work item claims a disjoint output range (atomic_add returns the old + value, which is exactly the number of earlier claims); pass two + scatters with a private cursor and no atomics. LDS atomic operations + per work item per round fall from 2 * unit_size (384 at unit_size + 192) to 16. The cost is a 16-entry private array indexed by data, + which AMD may put in scratch. + + wavehist X16RS_SORT_PARTS private copies of the histogram and the offset + counters in LDS, selected by (local_id >> 5) & (PARTS - 1), i.e. one + per wave32 at local_size 256. Same number of atomic operations as + stock, but each counter is touched by 32 lanes instead of 256. This + isolates cross-wave address contention from the atomic count itself. + + ballot subgroup aggregation. One sub_group_ballot per bucket per hash + collapses a subgroup's 32 increments into a single atomic_add of the + population count, so the atomic COUNT falls, but sixteen ballots + replace one DS instruction. Requires cl_khr_subgroup_ballot; the tree + #errors rather than falling back, so a silent fallback cannot be + mistaken for "no measurable difference". +""" +import os +import re +import shutil +import sys + +# ---------------------------------------------------------------- helpers + +def copy_tree(src, dst): + if os.path.isdir(dst): + shutil.rmtree(dst) + os.makedirs(dst) + for name in os.listdir(src): + if name.endswith(".cl"): + shutil.copy(os.path.join(src, name), os.path.join(dst, name)) + + +def write(dst, name, text): + open(os.path.join(dst, name), "w", encoding="utf-8").write(text) + + +def replace_once(text, old, new): + n = text.count(old) + assert n == 1, "expected 1 occurrence of %r, found %d" % (old[:70], n) + return text.replace(old, new) + + +# The sort, exactly as shipped: from the counter reset down to and including the +# barrier that publishes local_order. Everything after it is the dispatch loop. +SORT_START = " if ((local_id) < 16) { \\\n (histogram)[(local_id)] = 0; \\\n" +SORT_END = " barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \\\n" + + +def split_sort(base): + i = base.index(SORT_START) + j = base.index(SORT_END, i) + len(SORT_END) + assert base.count(SORT_START) == 1 + return base[:i], base[i:j], base[j:] + + +# --------------------------------------------------------------- variants + +def build_stock(src, out, base, kernels): + d = os.path.join(out, "stock") + copy_tree(src, d) + + +def build_double(src, out, base, kernels): + head, sort, tail = split_sort(base) + d = os.path.join(out, "double") + copy_tree(src, d) + write(d, "x16rs.cl", head + sort + sort + tail) + + +PRIVHIST = """\ + unsigned int privhist[16]; \\ + for (unsigned int i = 0; i < 16; i++) { \\ + privhist[i] = 0; \\ + } \\ + if ((local_id) < 16) { \\ + (histogram)[(local_id)] = 0; \\ + } \\ + for (unsigned int h = 0; h < (unit_size); h++) { \\ + privhist[(local_hashes)[(index) + h].h4[7] % 16]++; \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + for (unsigned int i = 0; i < 16; i++) { \\ + privhist[i] = atomic_add(&(histogram)[i], privhist[i]); \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + if ((local_id) == 0) { \\ + (starting_index)[0] = 0; \\ + for (unsigned char i = 1; i < 16; i++) { \\ + (starting_index)[i] = (starting_index)[i - 1] + (histogram)[i - 1]; \\ + } \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + for (unsigned int h = 0; h < (unit_size); h++) { \\ + unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \\ + (local_order)[(starting_index)[mod] + privhist[mod]] = (index) + h; \\ + privhist[mod]++; \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \\ +""" + + +def build_quad(src, out, base, kernels): + """Four sorts per round. Same probe as `double` with three times the signal: + B/A - 1 should be three times the `double` deficit if the cost is linear, + which is the check that the probe is measuring the sort and not an artefact.""" + head, sort, tail = split_sort(base) + d = os.path.join(out, "quad") + copy_tree(src, d) + write(d, "x16rs.cl", head + sort * 4 + tail) + + +def build_privhist(src, out, base, kernels): + head, sort, tail = split_sort(base) + d = os.path.join(out, "privhist") + copy_tree(src, d) + write(d, "x16rs.cl", head + PRIVHIST + tail) + + +# PARTS copies of both counter arrays. The zeroing loop is strided so it is +# correct at any local_size, and the partition index is masked so it is correct +# even if local_size is larger than 32 * PARTS. +WAVEHIST = """\ + for (unsigned int i = (local_id); i < 16 * X16RS_SORT_PARTS; i += (local_size)) { \\ + (histogram)[i] = 0; \\ + (offset)[i] = 0; \\ + } \\ + const unsigned int sort_part = ((local_id) >> 5) & (X16RS_SORT_PARTS - 1); \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + for (unsigned int h = 0; h < (unit_size); h++) { \\ + unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \\ + atomic_inc(&(histogram)[sort_part * 16 + mod]); \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + if ((local_id) < 16) { \\ + unsigned int run = 0; \\ + for (unsigned int p = 0; p < X16RS_SORT_PARTS; p++) { \\ + unsigned int c = (histogram)[p * 16 + (local_id)]; \\ + (histogram)[p * 16 + (local_id)] = run; \\ + run += c; \\ + } \\ + (starting_index)[(local_id)] = run; \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + if ((local_id) == 0) { \\ + unsigned int run = 0; \\ + for (unsigned char i = 0; i < 16; i++) { \\ + unsigned int c = (starting_index)[i]; \\ + (starting_index)[i] = run; \\ + run += c; \\ + } \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + for (unsigned int h = 0; h < (unit_size); h++) { \\ + unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \\ + unsigned int pos = (starting_index)[mod] \\ + + (histogram)[sort_part * 16 + mod] \\ + + atomic_inc(&(offset)[sort_part * 16 + mod]); \\ + (local_order)[pos] = (index) + h; \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \\ +""" + + +def build_wavehist(src, out, base, kernels): + head, sort, tail = split_sort(base) + d = os.path.join(out, "wavehist") + copy_tree(src, d) + text = "#define X16RS_SORT_PARTS 8\n" + head + WAVEHIST + tail + write(d, "x16rs.cl", text) + for name, src_text in kernels.items(): + t = replace_once(src_text, + "__local unsigned int ALIGN histogram[16];", + "__local unsigned int ALIGN histogram[16 * X16RS_SORT_PARTS];") + t = replace_once(t, + "__local unsigned int ALIGN offset[16];", + "__local unsigned int ALIGN offset[16 * X16RS_SORT_PARTS];") + write(d, name, t) + + +BALLOT = """\ + if ((local_id) < 16) { \\ + (histogram)[(local_id)] = 0; \\ + (offset)[(local_id)] = 0; \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + for (unsigned int h = 0; h < (unit_size); h++) { \\ + unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \\ + for (unsigned int m = 0; m < 16; m++) { \\ + uint4 vote = sub_group_ballot(mod == m); \\ + unsigned int cnt = sub_group_ballot_bit_count(vote); \\ + if (cnt != 0 && get_sub_group_local_id() == sub_group_ballot_find_lsb(vote)) { \\ + atomic_add(&(histogram)[m], cnt); \\ + } \\ + } \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + if ((local_id) == 0) { \\ + (starting_index)[0] = 0; \\ + for (unsigned char i = 1; i < 16; i++) { \\ + (starting_index)[i] = (starting_index)[i - 1] + (histogram)[i - 1]; \\ + } \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE); \\ + for (unsigned int h = 0; h < (unit_size); h++) { \\ + unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \\ + unsigned int pos = 0; \\ + for (unsigned int m = 0; m < 16; m++) { \\ + uint4 vote = sub_group_ballot(mod == m); \\ + unsigned int cnt = sub_group_ballot_bit_count(vote); \\ + if (cnt == 0) { \\ + continue; \\ + } \\ + unsigned int lead = sub_group_ballot_find_lsb(vote); \\ + unsigned int claimed = 0; \\ + if (get_sub_group_local_id() == lead) { \\ + claimed = atomic_add(&(offset)[m], cnt); \\ + } \\ + claimed = sub_group_broadcast(claimed, lead); \\ + if (mod == m) { \\ + pos = (starting_index)[m] + claimed + sub_group_ballot_exclusive_scan(vote); \\ + } \\ + } \\ + (local_order)[pos] = (index) + h; \\ + } \\ + barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \\ +""" + +BALLOT_PROLOGUE = """\ +#pragma OPENCL EXTENSION cl_khr_subgroups : enable +#pragma OPENCL EXTENSION cl_khr_subgroup_ballot : enable +""" + + +def build_ballot(src, out, base, kernels): + head, sort, tail = split_sort(base) + d = os.path.join(out, "ballot") + copy_tree(src, d) + write(d, "x16rs.cl", BALLOT_PROLOGUE + head + BALLOT + tail) + + +# The shipped scan is 16 dependent read-modify-writes done by work item 0 while +# the other 255 wait on a barrier. This does the same 16 exclusive sums on 16 +# work items at once: every lane reads only, nothing is carried between lanes, so +# the dependent chain of 16 LDS round trips becomes one masked loop in one wave. +FASTSCAN_OLD = """\ + if ((local_id) == 0) { \\ + (starting_index)[0] = 0; \\ + for (unsigned char i = 1; i < 16; i++) { \\ + (starting_index)[i] = (starting_index)[i - 1] + (histogram)[i - 1]; \\ + } \\ + } \\ +""" + +FASTSCAN_NEW = """\ + if ((local_id) < 16) { \\ + unsigned int scan_sum = 0; \\ + for (unsigned int i = 0; i < (local_id); i++) { \\ + scan_sum += (histogram)[i]; \\ + } \\ + (starting_index)[(local_id)] = scan_sum; \\ + } \\ +""" + + +def build_fastscan(src, out, base, kernels): + d = os.path.join(out, "fastscan") + copy_tree(src, d) + write(d, "x16rs.cl", replace_once(base, FASTSCAN_OLD, FASTSCAN_NEW)) + + +BUILDERS = { + "stock": build_stock, + "fastscan": build_fastscan, + "double": build_double, + "quad": build_quad, + "privhist": build_privhist, + "wavehist": build_wavehist, + "ballot": build_ballot, +} + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + return 2 + src, out = sys.argv[1], sys.argv[2] + names = sys.argv[3:] or list(BUILDERS) + base = open(os.path.join(src, "x16rs.cl"), encoding="utf-8").read() + kernels = { + name: open(os.path.join(src, name), encoding="utf-8").read() + for name in ("x16rs_main.cl", "x16rs_diamond.cl") + } + os.makedirs(out, exist_ok=True) + for name in names: + BUILDERS[name](src, out, base, kernels) + print("built", os.path.join(out, name)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/x16rs_specialise_trees.py b/scripts/x16rs_specialise_trees.py new file mode 100644 index 00000000..43fe9f9f --- /dev/null +++ b/scripts/x16rs_specialise_trees.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Build compile-time-specialised OpenCL kernel trees for the x16rs gate. + +Unlike `x16rs_gate_trees.py`, every tree here is meant to hash CORRECTLY. The +only difference from the shipping tree is that `X16RS_UNIT_SIZE` and +`X16RS_LOCAL_SIZE` are defined at the top of `x16rs_main.cl`, which turns the +kernel's unit size and work-group size from a runtime argument and a runtime +query into literals the compiler can see. + +A tree built here is valid at EXACTLY ONE launch shape. Launching it at any +other unit size gives wrong hashes, so each one has to pass `x16rs_gate equiv` +at its own shape before any number from it is reported. + + python scripts/x16rs_specialise_trees.py x16rs/opencl 64 128 192 + +writes /us64, /us128, /us192 (local_size 256), plus +/stock, an unspecialised copy, so an A/B run compares two trees that +were both freshly compiled from source rather than one of them from the +shipping tree's binary cache. +""" +import os +import shutil +import sys + +MARKER = "#include \"sha3_256.cl\"\n" + + +def copy_tree(src, dst): + if os.path.isdir(dst): + shutil.rmtree(dst) + os.makedirs(dst) + for name in os.listdir(src): + if name.endswith(".cl"): + shutil.copy(os.path.join(src, name), os.path.join(dst, name)) + + +def specialise(src, dst, unit_size, local_size): + copy_tree(src, dst) + path = os.path.join(dst, "x16rs_main.cl") + text = open(path, encoding="utf-8").read() + assert text.count(MARKER) == 1, "x16rs_main.cl include block moved" + if unit_size is None and local_size is None: + return + defines = "" + if unit_size is not None: + defines += "#define X16RS_UNIT_SIZE %d\n" % unit_size + if local_size is not None: + defines += "#define X16RS_LOCAL_SIZE %d\n" % local_size + text = text.replace(MARKER, MARKER + "\n" + defines, 1) + # The host also compiles with -D; putting the defines in the SOURCE is what + # makes compile.rs's content fingerprint differ, so the tree cannot pick up + # another tree's cached binary. + open(path, "w", encoding="utf-8", newline="\n").write(text) + + +def main(): + if len(sys.argv) < 4: + print(__doc__) + return 2 + src, out = sys.argv[1], sys.argv[2] + units = [int(a) for a in sys.argv[3:]] + os.makedirs(out, exist_ok=True) + specialise(src, os.path.join(out, "stock"), None, None) + print("stock (no defines) ->", os.path.join(out, "stock")) + for unit_size in units: + dst = os.path.join(out, "us%d" % unit_size) + specialise(src, dst, unit_size, 256) + print("unit_size=%d local_size=256 -> %s" % (unit_size, dst)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/bin/x16rs_gate.rs b/src/bin/x16rs_gate.rs new file mode 100644 index 00000000..1eefce84 --- /dev/null +++ b/src/bin/x16rs_gate.rs @@ -0,0 +1,470 @@ +//! `x16rs_gate`, the gate every later kernel change is judged by. +//! +//! x16rs_gate equiv prove GPU == CPU byte for byte +//! x16rs_gate baseline fixed-work timing at repeat = 16 +//! x16rs_gate ab paired A/B between two OpenCL kernel trees +//! +//! `equiv` and `baseline` take `--backend ocl` (default) or `--backend cuda`. +//! Both backends run the same corpus, the same CPU oracle and the same +//! comparison; see `app/src/x16rs_gate.rs`. +//! +//! Exit code 0 only on a pass. Anything else is a failure a script can see: +//! +//! 1 built without the backend's feature, so it would have compared nothing +//! 2 bad usage +//! 3 the gate ran and FAILED (a hash differed, or an algorithm was untested) +//! 4 the device or the run errored +//! 5 `ab`: the two trees disagree, so their speeds cannot be compared +//! +//! Exit 1 exists because the trap it guards against has already been walked into +//! once here: `ocl` and `cuda` are optional features, so a plain `cargo test` +//! compiles NONE of either backend and is green over code that was never built. +//! A gate that quietly compared zero hashes would be worse than no gate, so this +//! binary refuses to start instead. + +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn parse(args: &[String], name: &str, default: T) -> T { + for pair in args.windows(2) { + if pair[0] == name { + if let Ok(value) = pair[1].parse::() { + return value; + } + eprintln!("bad value for {name}: {}", pair[1]); + std::process::exit(2); + } + } + default +} + +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn parse_string(args: &[String], name: &str, default: &str) -> String { + for pair in args.windows(2) { + if pair[0] == name { + return pair[1].clone(); + } + } + default.to_string() +} + +#[cfg(feature = "ocl")] +fn default_opencl_dir() -> String { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("x16rs") + .join("opencl") + .to_string_lossy() + .into_owned() +} + +#[cfg(any(feature = "ocl", feature = "cuda"))] +const USAGE: &str = "usage: + x16rs_gate equiv [--backend ocl|cuda] [--headers N] [--batches N] [--prod-batches N] + [--prod-thresholds N] [--work-groups N] [--local-size N] [--unit-size N] + [--threads N] + ocl: [--opencl-dir D] [--platform N] [--device IDS] + cuda: [--cuda-device N] + x16rs_gate baseline [--backend ocl|cuda] [--work-groups N] [--local-size N] [--unit-size N] + [--height N] [--batches N] [--runs N] [--warmup N] [--headers N] + ocl: [--opencl-dir D] [--platform N] [--device IDS] + cuda: [--cuda-device N] + x16rs_gate ab --opencl-dir A --opencl-dir-b B [--work-groups N] [--local-size N] + [--unit-size N] [--height N] [--batches N] [--pairs N] [--warmup N] + [--headers N] + OpenCL only: CUDA kernels are compiled into the binary by nvcc, so one + process cannot hold two of them."; + +/// Print, before anything runs, what the run is about to prove and what the CPU +/// side of it will cost. The oracle is linear in the production window, and an +/// operator who is about to wait ten minutes should be told so by the tool, not +/// discover it. +#[cfg(any(feature = "ocl", feature = "cuda"))] +fn announce_equiv( + prod_shape: app::x16rs_gate::Shape, + prod_batches: u32, + prod_thresholds: u32, + threads: usize, +) { + use app::x16rs_gate as gate; + println!("[gate] CPU oracle threads = {threads}"); + println!( + "[gate] every exhaustive window is {} nonces and EVERY one of them is compared \ + byte-for-byte against x16rs::block_hash", + gate::SHARE_LIST_CAPACITY + ); + if prod_batches > 0 { + let window = prod_shape.nonces(); + let ranks = gate::threshold_ranks(window, gate::SHARE_LIST_CAPACITY, prod_thresholds); + let (seconds, bytes) = + gate::oracle_cost(window.saturating_mul(prod_batches as u64), threads); + println!( + "[gate] production shape {prod_shape} = {window} nonces x {prod_batches} batch(es); \ + {} count thresholds; ONE wrong hash anywhere in a window slips past them all with p = {:.2e}", + ranks.len(), + gate::threshold_miss_probability(window, &ranks) + ); + println!( + "[gate] the CPU oracle for that is about {:.0}s on {threads} threads and {} MiB", + seconds, + bytes / (1024 * 1024) + ); + } +} + +/// Did this error describe the kernel's output being wrong, or a failure to run? +/// +/// Tagged at the source with `x16rs_gate::DETECTED` rather than matched by +/// phrase, so renaming a message cannot quietly turn a caught defect back into +/// "no GPU attached". +fn is_detection(error: &str) -> bool { + error.starts_with(app::x16rs_gate::DETECTED) +} + +fn main() { + #[cfg(not(any(feature = "ocl", feature = "cuda")))] + { + eprintln!( + "x16rs_gate was built with NEITHER GPU backend, so it would compare nothing.\n \ + OpenCL: cargo build --release --features ocl --bin x16rs_gate\n \ + CUDA: cargo build --release --features cuda --bin x16rs_gate\n\ + Note that `ocl` and `cuda` are optional features: a plain `cargo build` or \ + `cargo test` compiles neither backend." + ); + std::process::exit(1); + } + + #[cfg(any(feature = "ocl", feature = "cuda"))] + { + use app::x16rs_gate::{self, EquivParams, Shape}; + + let args: Vec = std::env::args().collect(); + let mode = args.get(1).cloned().unwrap_or_default(); + + // Default to whatever this binary can actually do. A CUDA-only build + // defaulting to OpenCL would greet a Colab operator with "no usable + // OpenCL device" and teach them nothing. + let default_backend = if cfg!(feature = "ocl") { "ocl" } else { "cuda" }; + let backend = parse_string(&args, "--backend", default_backend); + let cuda_device: i32 = parse(&args, "--cuda-device", 0); + let threads: usize = parse( + &args, + "--threads", + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(8), + ); + + println!( + "[gate] version = {} ({})", + app::HACASH_NODE_VERSION, + app::HACASH_NODE_BUILD_TIME + ); + println!("[gate] backend = {backend}"); + + // Refuse loudly rather than compare nothing. This is the CUDA half of + // the rule the OpenCL gate already had. + match backend.as_str() { + "ocl" | "opencl" => { + if !cfg!(feature = "ocl") { + eprintln!( + "[gate] --backend ocl needs the OpenCL build: \ + cargo build --release --features ocl --bin x16rs_gate" + ); + std::process::exit(1); + } + } + "cuda" | "nvidia" => { + if !cfg!(feature = "cuda") { + eprintln!( + "[gate] --backend cuda needs the CUDA build: \ + cargo build --release --features cuda --bin x16rs_gate\n\ + [gate] and that build only contains kernels if nvcc was found: \ + x16rs-cuda/build.rs prints `Using CUDA Toolkit at ...` when it was, and \ + only then sets cfg(cuda_available). Without it the crate compiles, every \ + call returns NotCompiled, and nothing is proved." + ); + std::process::exit(1); + } + #[cfg(feature = "cuda")] + if !x16rs_gate::cuda_kernels_available() { + eprintln!( + "[gate] this binary has the cuda feature but NO CUDA kernels: \ + x16rs-cuda/build.rs did not find nvcc at build time, so cfg(cuda_available) \ + is unset and every device call returns NotCompiled. Rebuild with CUDA_PATH \ + set. Refusing to run rather than report a gate that compared nothing." + ); + std::process::exit(1); + } + } + other => { + eprintln!("[gate] unknown --backend '{other}' (expected ocl or cuda)"); + std::process::exit(2); + } + } + let use_cuda = matches!(backend.as_str(), "cuda" | "nvidia"); + + #[cfg(feature = "ocl")] + let opencl_dir = parse_string(&args, "--opencl-dir", &default_opencl_dir()); + #[cfg(not(feature = "ocl"))] + let opencl_dir = String::new(); + let platform: u32 = parse(&args, "--platform", 0); + let device = parse_string(&args, "--device", "0"); + if !use_cuda { + println!("[gate] opencl_dir = {opencl_dir}"); + println!("[gate] platform = {platform}, device_ids = {device}"); + } else { + println!("[gate] cuda_device = {cuda_device}"); + } + + match mode.as_str() { + "equiv" => { + let headers: u32 = parse(&args, "--headers", 4); + let batches: u32 = parse(&args, "--batches", 1); + let prod_batches: u32 = parse(&args, "--prod-batches", 2); + + // Skipping the exhaustive pass has to be deliberate. + // + // Either flag at zero makes its loop body never run. The device + // is still opened, the production pass still satisfies every + // other condition, and the gate printed PASS having compared not + // one byte exhaustively. The byte-for-byte comparison is the + // whole reason this exists, so a typo must not be able to remove + // it and still return green. + // + // Production-only IS legitimate, for isolating a defect that + // appears only at 48x256x48, so it stays available behind a flag + // that says what it is doing. + let production_only = args.iter().any(|a| a == "--production-only"); + if (headers == 0 || batches == 0) && !production_only { + eprintln!( + "[gate] --headers {headers} --batches {batches} would skip the exhaustive \ + byte-for-byte comparison entirely, which is the point of this gate.\n\ + [gate] If that is what you meant, pass --production-only and the verdict \ + will say so." + ); + std::process::exit(2); + } + if production_only && prod_batches == 0 { + eprintln!( + "[gate] --production-only with --prod-batches 0 leaves nothing to compare." + ); + std::process::exit(2); + } + let prod_thresholds: u32 = parse(&args, "--prod-thresholds", 255); + let prod_shape = Shape { + work_groups: parse(&args, "--work-groups", 48), + local_size: parse(&args, "--local-size", 256), + unit_size: parse(&args, "--unit-size", 48), + }; + println!( + "[gate] equiv: {headers} header(s) x {batches} window(s) x {} launch shape(s) \ + x repeats {:?}, plus {prod_batches} production-shape batch(es) at {prod_shape}", + x16rs_gate::EXHAUSTIVE_SHAPES.len(), + x16rs_gate::GATE_HEIGHTS + .iter() + .map(|h| x16rs::block_hash_repeat(*h)) + .collect::>(), + ); + announce_equiv(prod_shape, prod_batches, prod_thresholds, threads); + + let params = EquivParams { + headers, + batches, + prod_shape, + prod_batches, + prod_thresholds, + threads, + }; + let outcome = if use_cuda { + #[cfg(feature = "cuda")] + { + x16rs_gate::run_equivalence_cuda(cuda_device, params) + } + #[cfg(not(feature = "cuda"))] + unreachable!() + } else { + #[cfg(feature = "ocl")] + { + x16rs_gate::run_equivalence( + &opencl_dir, + platform, + &device, + params.headers, + params.batches, + params.prod_shape, + params.prod_batches, + params.prod_thresholds, + params.threads, + ) + } + #[cfg(not(feature = "ocl"))] + unreachable!() + }; + match outcome { + Ok(report) => { + println!("\n================ BYTE-EQUIVALENCE GATE ================"); + print!("{}", report.render()); + if report.passed() { + if report.exhaustive_batches == 0 { + println!( + " RESULT: PASS (production shape only; NO exhaustive \ + byte-for-byte comparison was run)" + ); + } else { + println!(" RESULT: PASS"); + } + std::process::exit(0); + } + if let Some(why) = report.failure_reason() { + println!(" WHY: {why}"); + } + println!(" RESULT: FAIL"); + std::process::exit(3); + } + // A detection is not a device error, and the difference is the + // whole value of the gate. + // + // Several checks report a broken kernel by returning Err: the + // production count threshold, the best-hash reduction not being + // the window minimum, and a window dump with a wrong hit count, + // an out-of-window nonce, a duplicate or a missing one. Mapping + // every Err to exit 4 made the wrapper scripts announce "the + // gate could not open the device, nothing was compared" on a run + // where the gate had just caught the fault it exists to catch. + // + // That mattered most where it hurt most: the exhaustive shapes + // are tiny (1x256x4 up to 4x256x1), so a defect that only appears + // at the production shape is visible ONLY through one of these + // Err paths. + // + // So a message that names a comparison exits 3, like any other + // mismatch, and only a genuine failure to run exits 4. + Err(error) => { + eprintln!("[gate] ERROR: {error}"); + if is_detection(&error) { + println!(" WHY: {error}"); + println!(" RESULT: FAIL"); + std::process::exit(3); + } + std::process::exit(4); + } + } + } + "baseline" => { + let shape = Shape { + work_groups: parse(&args, "--work-groups", 48), + local_size: parse(&args, "--local-size", 256), + unit_size: parse(&args, "--unit-size", 48), + }; + let height: u64 = parse(&args, "--height", x16rs_gate::REPEAT16_HEIGHT); + let batches: u32 = parse(&args, "--batches", 12); + let runs: u32 = parse(&args, "--runs", 9); + let warmup: u32 = parse(&args, "--warmup", 4); + let headers: u32 = parse(&args, "--headers", 4); + let outcome = if use_cuda { + #[cfg(feature = "cuda")] + { + x16rs_gate::run_baseline_cuda( + cuda_device, + shape, + height, + batches, + runs, + warmup, + headers, + ) + } + #[cfg(not(feature = "cuda"))] + unreachable!() + } else { + #[cfg(feature = "ocl")] + { + x16rs_gate::run_baseline( + &opencl_dir, + platform, + &device, + shape, + height, + batches, + runs, + warmup, + headers, + ) + } + #[cfg(not(feature = "ocl"))] + unreachable!() + }; + match outcome { + Ok(report) => { + println!("\n================ FIXED-WORK BASELINE ================"); + print!("{}", report.render()); + std::process::exit(0); + } + Err(error) => { + eprintln!("[gate] ERROR: {error}"); + std::process::exit(4); + } + } + } + "ab" => { + #[cfg(not(feature = "ocl"))] + { + eprintln!( + "[gate] `ab` is OpenCL only. It alternates two kernel TREES inside one \ + process, which OpenCL allows because it compiles kernels at runtime from \ + a directory. nvcc compiles block_miner.cu into this binary, so a CUDA \ + process holds exactly one kernel build." + ); + std::process::exit(2); + } + #[cfg(feature = "ocl")] + { + if use_cuda { + eprintln!( + "[gate] `ab` is OpenCL only: nvcc compiles the CUDA kernels into this \ + binary, so one process cannot hold two of them to alternate. Compare \ + two CUDA builds with `baseline` across processes and treat anything \ + under the ~2.6% between-process spread as unresolved." + ); + std::process::exit(2); + } + let shape = Shape { + work_groups: parse(&args, "--work-groups", 48), + local_size: parse(&args, "--local-size", 256), + unit_size: parse(&args, "--unit-size", 48), + }; + let dir_b = parse_string(&args, "--opencl-dir-b", &opencl_dir); + let height: u64 = parse(&args, "--height", x16rs_gate::REPEAT16_HEIGHT); + let batches: u32 = parse(&args, "--batches", 60); + let pairs: u32 = parse(&args, "--pairs", 11); + let warmup: u32 = parse(&args, "--warmup", 1200); + let headers: u32 = parse(&args, "--headers", 4); + match x16rs_gate::run_ab( + &opencl_dir, + &dir_b, + platform, + &device, + shape, + height, + batches, + pairs, + warmup, + headers, + ) { + Ok(report) => { + println!("\n================ PAIRED A/B ================"); + print!("{}", report.render()); + std::process::exit(if report.identical_output { 0 } else { 5 }); + } + Err(error) => { + eprintln!("[gate] ERROR: {error}"); + std::process::exit(4); + } + } + } + } + _ => { + eprintln!("{USAGE}"); + std::process::exit(2); + } + } + } +} diff --git a/sys/src/config.rs b/sys/src/config.rs index a3b8db69..00c1f0f8 100644 --- a/sys/src/config.rs +++ b/sys/src/config.rs @@ -98,7 +98,7 @@ fn parse_ini_content(content: &str) -> Result { return Err(format!("line {}: key cannot be empty", num + 1)); } map.entry(section.clone()) - .or_insert_with(HashMap::new) + .or_default() .insert(key, value); } Ok(map) diff --git a/testkit/src/sim/memchain.rs b/testkit/src/sim/memchain.rs index 501af2a8..1db80e19 100644 --- a/testkit/src/sim/memchain.rs +++ b/testkit/src/sim/memchain.rs @@ -18,9 +18,9 @@ use basis::interface::{ ActExec, Block, BlockRead, Context, Logs, State, StateOperat, Transaction, TransactionRead, }; use field::{ - AddrOrList, Address, Amount, AssetAmt, AssetSmelt, BlockHeight, BytesW1, DIAMOND_STATUS_NORMAL, - DiamondName, DiamondNumber, DiamondSmelt, DiamondSto, Field, Fixed8, Fixed16, Fold64, Hash, - Satoshi, Serialize, Timestamp, Uint1, Uint2, Uint4, + AddrOrList, Address, Amount, AssetAmt, AssetSmelt, BlockHeight, BytesW1, ChannelId, ChannelSto, + DIAMOND_STATUS_NORMAL, DiamondName, DiamondNumber, DiamondSmelt, DiamondSto, Field, Fixed8, + Fixed16, Fold64, Hash, Satoshi, Serialize, Timestamp, Uint1, Uint2, Uint4, }; use protocol::block::BlockV1; use protocol::context::{ContextInst, TX_GAS_BUDGET_CAP_BYTE, decode_gas_budget}; @@ -756,12 +756,27 @@ impl MemChain { /// output because the chain does not infer VM return values from raw bytes. pub fn submit_formal_raw(&mut self, raw: &[u8], output: TxOutput) -> Ret { let parsed = Self::parse_formal_type3_raw(raw)?; + Ok(self.submit_parsed_formal(parsed, output)) + } + + /// Submit any externally-built, signed production transaction. + /// + /// Unlike [`MemChain::submit_formal_raw`], this intentionally accepts legacy + /// Type2 transactions as well as Type3. It exists for protocol integration + /// tests that must exercise the exact wallet wire format through the real + /// block transaction executor. + pub fn submit_signed_transaction_raw(&mut self, raw: &[u8], output: TxOutput) -> Ret { + let parsed = Self::parse_transaction_raw(raw)?; + Ok(self.submit_parsed_formal(parsed, output)) + } + + fn submit_parsed_formal(&mut self, parsed: Box, output: TxOutput) -> Hash { let hash = parsed.hash(); self.pending.push(PendingTx { tx: PendingTxKind::Formal(parsed), op: PendingOp::FormalTx { output }, }); - Ok(hash) + hash } pub fn build_formal_actions_raw( @@ -1237,6 +1252,13 @@ impl MemChain { bal.map(|b| b.hacash).unwrap_or_default() } + /// Read a payment-channel record from the persistent state. + pub fn channel(&self, channel_id: &ChannelId) -> Option { + let mut state = self.state.clone_state(); + let state_dyn: &mut dyn State = state.as_mut(); + mint::oprate::MintState::wrap(state_dyn).channel(channel_id) + } + pub fn satoshi(&self, addr: &Address) -> Satoshi { let mut state = self.state.clone_state(); let state_dyn: &mut dyn State = state.as_mut(); @@ -1651,20 +1673,25 @@ impl MemChain { } fn parse_formal_type3_raw(raw: &[u8]) -> Ret> { + let parsed = Self::parse_transaction_raw(raw)?; + if parsed.as_read().ty() != TransactionType3::TYPE { + return Err(format!( + "formal tx raw parse expected TransactionType3, got type {}", + parsed.as_read().ty() + )); + } + Ok(parsed) + } + + fn parse_transaction_raw(raw: &[u8]) -> Ret> { let (parsed, used) = transaction_create(raw)?; if used != raw.len() { return Err(format!( - "formal tx raw parse did not consume all bytes: used {}, total {}", + "transaction raw parse did not consume all bytes: used {}, total {}", used, raw.len() )); } - if parsed.as_read().ty() != TransactionType3::TYPE { - return Err(format!( - "formal tx raw parse expected TransactionType3, got type {}", - parsed.as_read().ty() - )); - } Ok(parsed) } diff --git a/tests/hpay_composite_channel_close.rs b/tests/hpay_composite_channel_close.rs new file mode 100644 index 00000000..2157a6bf --- /dev/null +++ b/tests/hpay_composite_channel_close.rs @@ -0,0 +1,367 @@ +use basis::interface::{Transaction, TransactionRead}; +use field::{ + AddrHac, AddrOrPtr, Address, Amount, CHANNEL_STATUS_AGREEMENT_CLOSED, CHANNEL_STATUS_OPENING, + ChannelId, Field, Serialize, +}; +use mint::action::{ChannelClose, ChannelOpen}; +use protocol::action::HacFromToTrs; +use protocol::transaction::TransactionType2; +use sys::Account; +use testkit::sim::memchain::{MemChain, TxOutput}; + +const FEE_ZHU: u64 = 100_000; +const USER_START_ZHU: u64 = 5_000_000; +const HUB_START_ZHU: u64 = 1_000_000; +const USER_DEPOSIT_ZHU: u64 = 1_000_000; +const HUB_DEPOSIT_ZHU: u64 = 1_000_000; +const DELTA_ZHU: u64 = 100_000; + +struct Fixture { + chain: MemChain, + user: Account, + hub: Account, + miner: Address, + channel_id: ChannelId, +} + +fn address(account: &Account) -> Address { + Address::from(account.address().clone()) +} + +fn amount_zhu(amount: Amount) -> u64 { + amount.to_238_u64().expect("test amount must fit zhu") +} + +fn action14(from: Address, to: Address, amount_zhu: u64) -> HacFromToTrs { + let mut action = HacFromToTrs::new(); + action.from = AddrOrPtr::from_addr(from); + action.to = AddrOrPtr::from_addr(to); + action.hacash = Amount::unit238(amount_zhu); + action +} + +fn signed_type2( + main: &Account, + other: &Account, + timestamp: u64, + actions: Vec>, +) -> TransactionType2 { + let mut tx = TransactionType2::new_by(address(main), Amount::unit238(FEE_ZHU), timestamp); + for action in actions { + tx.push_action(action).expect("push action"); + } + tx.fill_sign(main).expect("main signature"); + tx.fill_sign(other).expect("counterparty signature"); + tx.verify_signature() + .expect("complete bilateral signatures"); + tx +} + +fn open_fixture(seed: u8, hub_start_zhu: u64) -> Fixture { + open_fixture_with_balances(seed, USER_START_ZHU, hub_start_zhu) +} + +fn open_fixture_with_balances(seed: u8, user_start_zhu: u64, hub_start_zhu: u64) -> Fixture { + let mut chain = MemChain::new(); + let user = Account::create_by(&format!("hpay-composite-user-{seed}")).expect("user account"); + let hub = Account::create_by(&format!("hpay-composite-hub-{seed}")).expect("hub account"); + let miner = address( + &Account::create_by(&format!("hpay-composite-miner-{seed}")).expect("miner account"), + ); + let user_address = address(&user); + let hub_address = address(&hub); + let channel_id = ChannelId::from([seed; 16]); + + chain.mint_hac(&user_address, user_start_zhu); + chain.mint_hac(&hub_address, hub_start_zhu); + + let mut open = ChannelOpen::new(); + open.channel_id = channel_id; + open.left_bill = AddrHac { + address: user_address, + amount: Amount::unit238(USER_DEPOSIT_ZHU), + }; + open.right_bill = AddrHac { + address: hub_address, + amount: Amount::unit238(HUB_DEPOSIT_ZHU), + }; + let open_tx = signed_type2(&user, &hub, 1_730_100_001, vec![Box::new(open)]); + let open_hash = chain + .submit_signed_transaction_raw(&open_tx.serialize(), TxOutput::None) + .expect("submit exact Type2 open bytes"); + chain + .confirm_formal_block(miner) + .expect("execute open in a real formal block") + .expect_success(&open_hash); + + let channel = chain.channel(&channel_id).expect("opened channel state"); + assert_eq!(channel.status, CHANNEL_STATUS_OPENING); + assert_eq!(*channel.reuse_version, 1); + + Fixture { + chain, + user, + hub, + miner, + channel_id, + } +} + +fn close_action(channel_id: ChannelId) -> ChannelClose { + let mut close = ChannelClose::new(); + close.channel_id = channel_id; + close +} + +#[test] +fn composite_close_user_to_hub_executes_exact_final_balances() { + let mut fixture = open_fixture(11, HUB_START_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_002, + vec![ + Box::new(close_action(fixture.channel_id)), + Box::new(action14(user_address, hub_address, DELTA_ZHU)), + ], + ); + assert_eq!( + close_tx + .actions() + .iter() + .map(|a| a.kind()) + .collect::>(), + vec![3, 14] + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .expect("submit exact composite close bytes"); + fixture + .chain + .confirm_formal_block(fixture.miner) + .expect("execute composite close in a real formal block") + .expect_success(&hash); + + assert_eq!( + amount_zhu(fixture.chain.balance(&user_address)), + USER_START_ZHU - (2 * FEE_ZHU) - DELTA_ZHU + ); + assert_eq!( + amount_zhu(fixture.chain.balance(&hub_address)), + HUB_START_ZHU + DELTA_ZHU + ); + assert_eq!( + fixture.chain.channel(&fixture.channel_id).unwrap().status, + CHANNEL_STATUS_AGREEMENT_CLOSED + ); +} + +#[test] +fn composite_close_hub_to_user_executes_exact_final_balances() { + let mut fixture = open_fixture(12, HUB_START_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_003, + vec![ + Box::new(close_action(fixture.channel_id)), + Box::new(action14(hub_address, user_address, DELTA_ZHU)), + ], + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .expect("submit exact reverse composite close bytes"); + fixture + .chain + .confirm_formal_block(fixture.miner) + .expect("execute reverse composite close in a real formal block") + .expect_success(&hash); + + assert_eq!( + amount_zhu(fixture.chain.balance(&user_address)), + USER_START_ZHU - (2 * FEE_ZHU) + DELTA_ZHU + ); + assert_eq!( + amount_zhu(fixture.chain.balance(&hub_address)), + HUB_START_ZHU - DELTA_ZHU + ); +} + +#[test] +fn unchanged_close_executes_action3_only() { + let mut fixture = open_fixture(13, HUB_START_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_004, + vec![Box::new(close_action(fixture.channel_id))], + ); + assert_eq!( + close_tx + .actions() + .iter() + .map(|a| a.kind()) + .collect::>(), + vec![3] + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .expect("submit unchanged close bytes"); + fixture + .chain + .confirm_formal_block(fixture.miner) + .expect("execute unchanged close in a real formal block") + .expect_success(&hash); + + assert_eq!( + amount_zhu(fixture.chain.balance(&user_address)), + USER_START_ZHU - (2 * FEE_ZHU) + ); + assert_eq!( + amount_zhu(fixture.chain.balance(&hub_address)), + HUB_START_ZHU + ); +} + +#[test] +fn failed_action14_rolls_back_action3_fee_and_balances() { + let mut fixture = open_fixture(14, HUB_DEPOSIT_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let before_state = fixture.chain.state_entries(); + let before_user = fixture.chain.balance(&user_address); + let before_hub = fixture.chain.balance(&hub_address); + + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_005, + vec![ + Box::new(close_action(fixture.channel_id)), + Box::new(action14(hub_address, user_address, HUB_DEPOSIT_ZHU + 1)), + ], + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .expect("submit failing composite close bytes"); + let block = fixture + .chain + .confirm_formal_block_observing_failures(fixture.miner) + .expect("observe failed production transaction execution"); + block + .receipt(&hash) + .expect("failed transaction receipt") + .expect_error_contains("insufficient"); + + assert_eq!(fixture.chain.state_entries(), before_state); + assert_eq!(fixture.chain.balance(&user_address), before_user); + assert_eq!(fixture.chain.balance(&hub_address), before_hub); + assert_eq!( + fixture.chain.channel(&fixture.channel_id).unwrap().status, + CHANNEL_STATUS_OPENING + ); +} + +#[test] +fn close_rolls_back_when_final_user_principal_cannot_pay_fee() { + let mut fixture = open_fixture_with_balances(15, USER_DEPOSIT_ZHU + FEE_ZHU, HUB_DEPOSIT_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let before_state = fixture.chain.state_entries(); + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_006, + vec![ + Box::new(close_action(fixture.channel_id)), + Box::new(action14(user_address, hub_address, USER_DEPOSIT_ZHU)), + ], + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .unwrap(); + let block = fixture + .chain + .confirm_formal_block_observing_failures(fixture.miner) + .unwrap(); + block + .receipt(&hash) + .unwrap() + .expect_error_contains("insufficient"); + assert_eq!(fixture.chain.state_entries(), before_state); + assert_eq!( + fixture.chain.channel(&fixture.channel_id).unwrap().status, + CHANNEL_STATUS_OPENING + ); +} + +#[test] +fn close_succeeds_when_final_user_principal_exactly_covers_fee() { + let mut fixture = open_fixture_with_balances(16, USER_DEPOSIT_ZHU + FEE_ZHU, HUB_DEPOSIT_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_007, + vec![ + Box::new(close_action(fixture.channel_id)), + Box::new(action14( + user_address, + hub_address, + USER_DEPOSIT_ZHU - FEE_ZHU, + )), + ], + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .unwrap(); + fixture + .chain + .confirm_formal_block(fixture.miner) + .unwrap() + .expect_success(&hash); + assert_eq!(amount_zhu(fixture.chain.balance(&user_address)), 0); +} + +#[test] +fn close_succeeds_when_final_user_principal_exceeds_fee() { + let mut fixture = open_fixture_with_balances(17, USER_DEPOSIT_ZHU + FEE_ZHU, HUB_DEPOSIT_ZHU); + let user_address = address(&fixture.user); + let hub_address = address(&fixture.hub); + let close_tx = signed_type2( + &fixture.user, + &fixture.hub, + 1_730_100_008, + vec![ + Box::new(close_action(fixture.channel_id)), + Box::new(action14( + user_address, + hub_address, + USER_DEPOSIT_ZHU - (2 * FEE_ZHU), + )), + ], + ); + let hash = fixture + .chain + .submit_signed_transaction_raw(&close_tx.serialize(), TxOutput::None) + .unwrap(); + fixture + .chain + .confirm_formal_block(fixture.miner) + .unwrap() + .expect_success(&hash); + assert_eq!(amount_zhu(fixture.chain.balance(&user_address)), FEE_ZHU); +} diff --git a/vm/contracts/hpay_channel_exit_v1.fitsh b/vm/contracts/hpay_channel_exit_v1.fitsh new file mode 100644 index 00000000..c10b6220 --- /dev/null +++ b/vm/contracts/hpay_channel_exit_v1.fitsh @@ -0,0 +1,231 @@ +pragma fitsh 1.0.0 + +contract HPAYChannelExitV1 { + const FUNDING = 1 + const OPEN = 2 + const CHALLENGING = 3 + const FINAL = 4 + const INITIAL_RENT_PERIODS = 100 + const MAX_RENT_STEP = 5000 + + abstract PayableHAC(from_addr: address, hacash: bytes) { + var status = storage_load("status") + if status != FUNDING as u8 { + throw "HPAY_FUNDING_STATUS" + } + var left = storage_load("left") + var right = storage_load("right") + var amount = hac_to_zhu(hacash) + if amount == 0 { + throw "HPAY_ZERO_DEPOSIT" + } + if from_addr == left { + var paid = storage_load("left_paid") + var expected = storage_load("left_deposit") + if paid != 0 as u64 { + throw "HPAY_LEFT_ALREADY_FUNDED" + } + if amount != expected { + throw "HPAY_LEFT_DEPOSIT_MISMATCH" + } + storage_edit("left_paid", amount) + } else { + if from_addr != right { + throw "HPAY_UNKNOWN_DEPOSITOR" + } + var right_paid = storage_load("right_paid") + var right_expected = storage_load("right_deposit") + if right_paid != 0 as u64 { + throw "HPAY_RIGHT_ALREADY_FUNDED" + } + if amount != right_expected { + throw "HPAY_RIGHT_DEPOSIT_MISMATCH" + } + storage_edit("right_paid", amount) + } + if storage_load("left_paid") == storage_load("left_deposit") && + storage_load("right_paid") == storage_load("right_deposit") { + storage_edit("status", OPEN as u8) + } + return 0 + } + + abstract PermitHAC(to_addr: address, hacash: bytes) { + if storage_load("status") != FINAL as u8 { + throw "HPAY_NOT_FINAL" + } + var amount = hac_to_zhu(hacash) + var left = storage_load("left") + var right = storage_load("right") + if to_addr == left { + if storage_load("left_claimed") != false { + throw "HPAY_LEFT_ALREADY_CLAIMED" + } + if amount != storage_load("left_balance") { + throw "HPAY_LEFT_PAYOUT_MISMATCH" + } + storage_edit("left_claimed", true) + return 0 + } + if to_addr != right { + throw "HPAY_UNKNOWN_RECIPIENT" + } + if storage_load("right_claimed") != false { + throw "HPAY_RIGHT_ALREADY_CLAIMED" + } + if amount != storage_load("right_balance") { + throw "HPAY_RIGHT_PAYOUT_MISMATCH" + } + storage_edit("right_claimed", true) + return 0 + } + + function external init( + network: bytes, + channel_id: bytes, + reuse: u32, + left: address, + right: address, + left_deposit: u64, + right_deposit: u64, + challenge_blocks: u64, + rent_periods: u64 + ) -> u32 { + assert size(network) == 32 + assert size(channel_id) == 16 + assert left != right + assert left_deposit > 0 + assert right_deposit == 0 + assert challenge_blocks > 0 + assert rent_periods == INITIAL_RENT_PERIODS + assert check_signature(left) + assert check_signature(right) + var total = left_deposit + right_deposit + storage_new("status", FUNDING as u8, rent_periods) + storage_new("network", network, rent_periods) + storage_new("channel_id", channel_id, rent_periods) + storage_new("reuse", reuse, rent_periods) + storage_new("left", left, rent_periods) + storage_new("right", right, rent_periods) + storage_new("left_deposit", left_deposit, rent_periods) + storage_new("right_deposit", right_deposit, rent_periods) + storage_new("left_paid", 0 as u64, rent_periods) + storage_new("right_paid", 0 as u64, rent_periods) + storage_new("total", total, rent_periods) + storage_new("serial", 0 as u64, rent_periods) + storage_new("left_balance", left_deposit, rent_periods) + storage_new("right_balance", right_deposit, rent_periods) + storage_new("challenge_blocks", challenge_blocks, rent_periods) + storage_new("deadline", 0 as u64, rent_periods) + storage_new("left_claimed", false, rent_periods) + storage_new("right_claimed", false, rent_periods) + return 0 + } + + function external renew(key: bytes, periods: u64) -> u32 { + assert size(key) > 0 + assert periods > 0 + assert periods <= MAX_RENT_STEP + // storage_recv is the canonical existence check and is valid while a + // lease is recoverable. storage_stat-based preflight incorrectly + // rejected that exact recovery state on-chain. + storage_recv(key, periods) + storage_rent(key, periods) + return 0 + } + + function external channel_status() -> u8 { + return storage_load("status") + } + + function bill_hash(serial: u64, left_balance: u64, right_balance: u64) -> bytes { + return sha3( + "HPAY/HVM-CHANNEL/V1" ++ + storage_load("network") ++ + (context_address() as bytes) ++ + storage_load("channel_id") ++ + (storage_load("reuse") as bytes) ++ + (storage_load("left") as bytes) ++ + (storage_load("right") as bytes) ++ + (storage_load("total") as bytes) ++ + (storage_load("challenge_blocks") as bytes) ++ + (serial as bytes) ++ + (left_balance as bytes) ++ + (right_balance as bytes) + ) + } + + function verify_bill( + serial: u64, + left_balance: u64, + right_balance: u64, + left_sign: bytes, + right_sign: bytes + ) -> u32 { + assert storage_load("left_paid") == storage_load("left_deposit") + assert storage_load("right_paid") == storage_load("right_deposit") + assert serial > storage_load("serial") + assert left_balance + right_balance == storage_load("total") + var commitment = this.bill_hash(serial, left_balance, right_balance) + assert verify_signature(commitment, storage_load("left"), left_sign) + assert verify_signature(commitment, storage_load("right"), right_sign) + return 0 + } + + function save_bill(serial: u64, left_balance: u64, right_balance: u64) -> u32 { + storage_edit("serial", serial) + storage_edit("left_balance", left_balance) + storage_edit("right_balance", right_balance) + return 0 + } + + function external cooperative_close( + serial: u64, + left_balance: u64, + right_balance: u64, + left_sign: bytes, + right_sign: bytes + ) -> u32 { + assert storage_load("status") != FINAL as u8 + this.verify_bill(serial, left_balance, right_balance, left_sign, right_sign) + this.save_bill(serial, left_balance, right_balance) + storage_edit("status", FINAL as u8) + return 0 + } + + function external challenge( + serial: u64, + left_balance: u64, + right_balance: u64, + left_sign: bytes, + right_sign: bytes + ) -> u32 { + assert storage_load("status") == OPEN as u8 + this.verify_bill(serial, left_balance, right_balance, left_sign, right_sign) + this.save_bill(serial, left_balance, right_balance) + storage_edit("deadline", block_height() + storage_load("challenge_blocks")) + storage_edit("status", CHALLENGING as u8) + return 0 + } + + function external respond( + serial: u64, + left_balance: u64, + right_balance: u64, + left_sign: bytes, + right_sign: bytes + ) -> u32 { + assert storage_load("status") == CHALLENGING as u8 + assert block_height() < storage_load("deadline") + this.verify_bill(serial, left_balance, right_balance, left_sign, right_sign) + this.save_bill(serial, left_balance, right_balance) + return 0 + } + + function external finalize() -> u32 { + assert storage_load("status") == CHALLENGING as u8 + assert block_height() >= storage_load("deadline") + storage_edit("status", FINAL as u8) + return 0 + } +} diff --git a/vm/contracts/hpay_channel_exit_v1.manifest.json b/vm/contracts/hpay_channel_exit_v1.manifest.json new file mode 100644 index 00000000..03fedfec --- /dev/null +++ b/vm/contracts/hpay_channel_exit_v1.manifest.json @@ -0,0 +1,48 @@ +{ + "schema": "hpay-hvm-channel-exit-manifest/1", + "contract_name": "HPAYChannelExitV1", + "protocol_domain": "HPAY/HVM-CHANNEL/V1", + "settlement_profile": "hpay-hvm-channel-v1", + "source_file": "hpay_channel_exit_v1.fitsh", + "source_sha256": "c0a430eb9769d1d506641c379bb8aaf708c7bac7d03694b60a4be03fd001dd06", + "bytecode_sha3": "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349", + "required_action_kinds": [40, 41, 44], + "initial_rent_periods": 100, + "maximum_renewal_step_periods": 5000, + "funding_model": { + "left_deposit": "positive", + "right_hub_deposit": "exactly_zero" + }, + "storage_keys": [ + "status", + "network", + "channel_id", + "reuse", + "left", + "right", + "left_deposit", + "right_deposit", + "left_paid", + "right_paid", + "total", + "serial", + "left_balance", + "right_balance", + "challenge_blocks", + "deadline", + "left_claimed", + "right_claimed" + ], + "lease_policy": { + "permissionless_renewal": true, + "must_renew_every_storage_key": true, + "production_watchtower_required": true + }, + "mainnet_deployment": { + "enabled": false, + "contract_address": null, + "deployment_tx_hash": null, + "deployment_height": null, + "independently_verified": false + } +} diff --git a/vm/contracts/hpay_channel_registry_v2.fitsh b/vm/contracts/hpay_channel_registry_v2.fitsh new file mode 100644 index 00000000..c75a5e71 --- /dev/null +++ b/vm/contracts/hpay_channel_registry_v2.fitsh @@ -0,0 +1,339 @@ +pragma fitsh 1.0.0 + +contract HPAYChannelRegistryV2 { + const FUNDING = 1 + const OPEN = 2 + const CHALLENGING = 3 + const FINAL = 4 + const INITIAL_RENT_PERIODS = 100 + const MAX_RENT_STEP = 5000 + + // The deploy transaction's main signer is the only Hub identity for this + // registry. Construct argv is the exact 32-byte HPAY network instance. + abstract Construct(network: bytes) { + assert size(network) == 32 + var hub = tx_main_addr() + assert check_signature(hub) + storage_new("g_network", network, INITIAL_RENT_PERIODS) + storage_new("g_hub", hub, INITIAL_RENT_PERIODS) + storage_new("g_locked", 0 as u64, INITIAL_RENT_PERIODS) + storage_new("g_left_claimable", 0 as u64, INITIAL_RENT_PERIODS) + storage_new("g_hub_claimable", 0 as u64, INITIAL_RENT_PERIODS) + storage_new("g_open_count", 0 as u64, INITIAL_RENT_PERIODS) + return 0 + } + + function key(prefix: bytes, left: address) -> bytes { + return prefix ++ (left as bytes) + } + + function load(prefix: bytes, left: address) { + return storage_load(this.key(prefix, left)) + } + + function create_channel_storage( + left: address, + channel_id: bytes, + reuse: u32, + left_deposit: u64, + challenge_blocks: u64, + rent_periods: u64 + ) -> u32 { + storage_new(this.key("c_status_", left), FUNDING as u8, rent_periods) + storage_new(this.key("c_id_", left), channel_id, rent_periods) + storage_new(this.key("c_reuse_", left), reuse, rent_periods) + storage_new(this.key("c_deposit_", left), left_deposit, rent_periods) + storage_new(this.key("c_paid_", left), 0 as u64, rent_periods) + storage_new(this.key("c_total_", left), left_deposit, rent_periods) + storage_new(this.key("c_serial_", left), 0 as u64, rent_periods) + storage_new(this.key("c_left_balance_", left), left_deposit, rent_periods) + storage_new(this.key("c_hub_balance_", left), 0 as u64, rent_periods) + storage_new(this.key("c_challenge_", left), challenge_blocks, rent_periods) + storage_new(this.key("c_deadline_", left), 0 as u64, rent_periods) + storage_new(this.key("c_left_claimed_", left), false, rent_periods) + return 0 + } + + function reset_channel_storage( + left: address, + channel_id: bytes, + reuse: u32, + left_deposit: u64, + challenge_blocks: u64 + ) -> u32 { + storage_edit(this.key("c_status_", left), FUNDING as u8) + storage_edit(this.key("c_id_", left), channel_id) + storage_edit(this.key("c_reuse_", left), reuse) + storage_edit(this.key("c_deposit_", left), left_deposit) + storage_edit(this.key("c_paid_", left), 0 as u64) + storage_edit(this.key("c_total_", left), left_deposit) + storage_edit(this.key("c_serial_", left), 0 as u64) + storage_edit(this.key("c_left_balance_", left), left_deposit) + storage_edit(this.key("c_hub_balance_", left), 0 as u64) + storage_edit(this.key("c_challenge_", left), challenge_blocks) + storage_edit(this.key("c_deadline_", left), 0 as u64) + storage_edit(this.key("c_left_claimed_", left), false) + return 0 + } + + function external init( + channel_id: bytes, + reuse: u32, + left: address, + left_deposit: u64, + challenge_blocks: u64, + rent_periods: u64 + ) -> u32 { + assert size(channel_id) == 16 + assert left != storage_load("g_hub") + assert left_deposit > 0 + assert challenge_blocks > 0 + assert rent_periods == INITIAL_RENT_PERIODS + assert check_signature(left) + assert check_signature(storage_load("g_hub")) + + var old_status = this.load("c_status_", left) + if old_status is nil { + assert reuse == 0 + this.create_channel_storage( + left, + channel_id, + reuse, + left_deposit, + challenge_blocks, + rent_periods + ) + } else { + assert old_status == FINAL as u8 + assert this.load("c_left_claimed_", left) == true + assert reuse == this.load("c_reuse_", left) + 1 + this.reset_channel_storage(left, channel_id, reuse, left_deposit, challenge_blocks) + } + return 0 + } + + abstract PayableHAC(from_addr: address, hacash: bytes) { + if this.load("c_status_", from_addr) != FUNDING as u8 { + throw "HPAY_FUNDING_STATUS" + } + var amount = hac_to_zhu(hacash) as u64 + if amount == 0 { + throw "HPAY_ZERO_DEPOSIT" + } + if this.load("c_paid_", from_addr) != 0 as u64 { + throw "HPAY_ALREADY_FUNDED" + } + if amount != this.load("c_deposit_", from_addr) { + throw "HPAY_DEPOSIT_MISMATCH" + } + storage_edit(this.key("c_paid_", from_addr), amount) + storage_edit(this.key("c_status_", from_addr), OPEN as u8) + storage_edit("g_locked", storage_load("g_locked") + amount) + storage_edit("g_open_count", storage_load("g_open_count") + 1) + return 0 + } + + abstract PermitHAC(to_addr: address, hacash: bytes) { + var amount = hac_to_zhu(hacash) as u64 + if amount == 0 { + throw "HPAY_ZERO_PAYOUT" + } + if to_addr == storage_load("g_hub") { + var hub_claimable = storage_load("g_hub_claimable") + if amount > hub_claimable { + throw "HPAY_HUB_PAYOUT_MISMATCH" + } + storage_edit("g_hub_claimable", hub_claimable - amount) + return 0 + } + if this.load("c_status_", to_addr) != FINAL as u8 { + throw "HPAY_NOT_FINAL" + } + if this.load("c_left_claimed_", to_addr) != false { + throw "HPAY_LEFT_ALREADY_CLAIMED" + } + if amount != this.load("c_left_balance_", to_addr) { + throw "HPAY_LEFT_PAYOUT_MISMATCH" + } + storage_edit(this.key("c_left_claimed_", to_addr), true) + storage_edit("g_left_claimable", storage_load("g_left_claimable") - amount) + return 0 + } + + function bill_hash( + left: address, + serial: u64, + left_balance: u64, + hub_balance: u64 + ) -> bytes { + return sha3( + "HPAY/HVM-CHANNEL-REGISTRY/V2" ++ + storage_load("g_network") ++ + (context_address() as bytes) ++ + this.load("c_id_", left) ++ + (this.load("c_reuse_", left) as bytes) ++ + (left as bytes) ++ + (storage_load("g_hub") as bytes) ++ + (this.load("c_total_", left) as bytes) ++ + (this.load("c_challenge_", left) as bytes) ++ + (serial as bytes) ++ + (left_balance as bytes) ++ + (hub_balance as bytes) + ) + } + + function verify_bill( + left: address, + serial: u64, + left_balance: u64, + hub_balance: u64, + left_sign: bytes, + hub_sign: bytes + ) -> u32 { + assert this.load("c_paid_", left) == this.load("c_deposit_", left) + assert serial > this.load("c_serial_", left) + assert left_balance + hub_balance == this.load("c_total_", left) + var commitment = this.bill_hash(left, serial, left_balance, hub_balance) + assert verify_signature(commitment, left, left_sign) + assert verify_signature(commitment, storage_load("g_hub"), hub_sign) + return 0 + } + + function save_bill( + left: address, + serial: u64, + left_balance: u64, + hub_balance: u64 + ) -> u32 { + storage_edit(this.key("c_serial_", left), serial) + storage_edit(this.key("c_left_balance_", left), left_balance) + storage_edit(this.key("c_hub_balance_", left), hub_balance) + return 0 + } + + function settle(left: address) -> u32 { + var total = this.load("c_total_", left) + var left_balance = this.load("c_left_balance_", left) + var hub_balance = this.load("c_hub_balance_", left) + storage_edit("g_locked", storage_load("g_locked") - total) + storage_edit( + "g_left_claimable", + storage_load("g_left_claimable") + left_balance + ) + storage_edit( + "g_hub_claimable", + storage_load("g_hub_claimable") + hub_balance + ) + storage_edit("g_open_count", storage_load("g_open_count") - 1) + if left_balance == 0 { + storage_edit(this.key("c_left_claimed_", left), true) + } + storage_edit(this.key("c_status_", left), FINAL as u8) + return 0 + } + + function external cooperative_close( + left: address, + serial: u64, + left_balance: u64, + hub_balance: u64, + left_sign: bytes, + hub_sign: bytes + ) -> u32 { + assert this.load("c_status_", left) != FINAL as u8 + this.verify_bill(left, serial, left_balance, hub_balance, left_sign, hub_sign) + this.save_bill(left, serial, left_balance, hub_balance) + this.settle(left) + return 0 + } + + function external challenge( + left: address, + serial: u64, + left_balance: u64, + hub_balance: u64, + left_sign: bytes, + hub_sign: bytes + ) -> u32 { + assert this.load("c_status_", left) == OPEN as u8 + this.verify_bill(left, serial, left_balance, hub_balance, left_sign, hub_sign) + this.save_bill(left, serial, left_balance, hub_balance) + storage_edit( + this.key("c_deadline_", left), + block_height() + this.load("c_challenge_", left) + ) + storage_edit(this.key("c_status_", left), CHALLENGING as u8) + return 0 + } + + function external respond( + left: address, + serial: u64, + left_balance: u64, + hub_balance: u64, + left_sign: bytes, + hub_sign: bytes + ) -> u32 { + assert this.load("c_status_", left) == CHALLENGING as u8 + assert block_height() < this.load("c_deadline_", left) + this.verify_bill(left, serial, left_balance, hub_balance, left_sign, hub_sign) + this.save_bill(left, serial, left_balance, hub_balance) + return 0 + } + + function external finalize(left: address) -> u32 { + assert this.load("c_status_", left) == CHALLENGING as u8 + assert block_height() >= this.load("c_deadline_", left) + this.settle(left) + return 0 + } + + function renew_one(key: bytes, periods: u64) -> u32 { + storage_recv(key, periods) + storage_rent(key, periods) + return 0 + } + + function external renew_registry(periods: u64) -> u32 { + assert periods > 0 + assert periods <= MAX_RENT_STEP + this.renew_one("g_network", periods) + this.renew_one("g_hub", periods) + this.renew_one("g_locked", periods) + this.renew_one("g_left_claimable", periods) + this.renew_one("g_hub_claimable", periods) + this.renew_one("g_open_count", periods) + return 0 + } + + function external renew_channel(left: address, periods: u64) -> u32 { + assert periods > 0 + assert periods <= MAX_RENT_STEP + this.renew_one(this.key("c_status_", left), periods) + this.renew_one(this.key("c_id_", left), periods) + this.renew_one(this.key("c_reuse_", left), periods) + this.renew_one(this.key("c_deposit_", left), periods) + this.renew_one(this.key("c_paid_", left), periods) + this.renew_one(this.key("c_total_", left), periods) + this.renew_one(this.key("c_serial_", left), periods) + this.renew_one(this.key("c_left_balance_", left), periods) + this.renew_one(this.key("c_hub_balance_", left), periods) + this.renew_one(this.key("c_challenge_", left), periods) + this.renew_one(this.key("c_deadline_", left), periods) + this.renew_one(this.key("c_left_claimed_", left), periods) + return 0 + } + + function external registry_totals() { + return [ + storage_load("g_locked"), + storage_load("g_left_claimable"), + storage_load("g_hub_claimable"), + storage_load("g_open_count") + ] + } + + function external channel_status(left: address) { + return this.load("c_status_", left) + } +} diff --git a/vm/contracts/hpay_channel_registry_v2.manifest.json b/vm/contracts/hpay_channel_registry_v2.manifest.json new file mode 100644 index 00000000..52345205 --- /dev/null +++ b/vm/contracts/hpay_channel_registry_v2.manifest.json @@ -0,0 +1,69 @@ +{ + "schema": "hpay-hvm-channel-registry-manifest/2", + "contract_name": "HPAYChannelRegistryV2", + "protocol_domain": "HPAY/HVM-CHANNEL-REGISTRY/V2", + "settlement_profile": "hpay-hvm-shared-registry-v2", + "source_file": "hpay_channel_registry_v2.fitsh", + "source_sha256": "58ab4ba8931190a5b83f5b30a96d842281adf9d7e7069cbf8bf79a68945ae8a8", + "bytecode_sha3": "276d8c205296cc50d06244c84d52c5a9f6f4711e0abae67f416e4fc79c9294be", + "required_action_kinds": [40, 41, 44], + "initial_rent_periods": 100, + "maximum_renewal_step_periods": 5000, + "deployment_model": { + "scope": "one_registry_per_hub_and_network", + "hub_binding": "contract_deploy_main_signer", + "network_binding": "exact_32_byte_constructor_argument", + "per_channel_contract_deploy": false + }, + "channel_model": { + "maximum_active_channels_per_left_address": 1, + "channel_identity": "left_address_channel_id_reuse", + "first_reuse": 0, + "next_reuse": "exact_previous_plus_one", + "right_party": "registry_hub", + "right_hub_deposit": "exactly_zero", + "left_deposit": "positive" + }, + "registry_storage_keys": [ + "g_network", + "g_hub", + "g_locked", + "g_left_claimable", + "g_hub_claimable", + "g_open_count" + ], + "channel_storage_prefixes": [ + "c_status_", + "c_id_", + "c_reuse_", + "c_deposit_", + "c_paid_", + "c_total_", + "c_serial_", + "c_left_balance_", + "c_hub_balance_", + "c_challenge_", + "c_deadline_", + "c_left_claimed_" + ], + "payout_model": { + "left_claim": "exact_once_per_finalized_channel", + "hub_claim": "partial_or_full_from_aggregate_claimable", + "hub_credit_timing": "finalization" + }, + "lease_policy": { + "permissionless_registry_renewal": true, + "permissionless_channel_renewal": true, + "must_renew_every_registry_key": true, + "must_renew_every_channel_key": true, + "production_watchtower_required": true + }, + "mainnet_deployment": { + "enabled": false, + "contract_address": null, + "deployment_tx_hash": null, + "deployment_height": null, + "independently_verified": false, + "external_audit_complete": false + } +} diff --git a/vm/tests/hpay_channel_exit.rs b/vm/tests/hpay_channel_exit.rs new file mode 100644 index 00000000..f5d91421 --- /dev/null +++ b/vm/tests/hpay_channel_exit.rs @@ -0,0 +1,856 @@ +use basis::method::verify_signature; +use field::{AddrOrPtr, Address, Amount, Field, Hash, Serialize, Sign, Uint4}; +use protocol::action::{HacFromToTrs, HacToTrs}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use sys::Account; +use testkit::sim::memchain::{MemChain, TxOutput}; +use vm::value::Value; +use vm::{ContractAddress, VMStateRead}; + +const DOMAIN: &[u8] = b"HPAY/HVM-CHANNEL/V1"; +const CHALLENGE_BLOCKS: u64 = 12; + +// Canonical versioned source. The artifact remains disabled for mainnet until +// its deployment and every live storage lease are independently verified. +const CONTRACT_SOURCE: &str = include_str!("../contracts/hpay_channel_exit_v1.fitsh"); +const CONTRACT_MANIFEST: &str = include_str!("../contracts/hpay_channel_exit_v1.manifest.json"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Status { + Open, + Challenging { deadline: u64 }, + Final, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExitError { + Binding, + Conservation, + InvalidSignature, + StaleSerial, + InvalidStatus, + ChallengeExpired, + ChallengePending, + HeightOverflow, +} + +#[derive(Clone)] +struct Bill { + network: [u8; 32], + contract: Address, + channel_id: [u8; 16], + reuse: u32, + left: Address, + right: Address, + total: u64, + challenge_blocks: u64, + serial: u64, + left_balance: u64, + right_balance: u64, + left_sign: Sign, + right_sign: Sign, +} + +impl Bill { + fn unsigned( + network: [u8; 32], + contract: Address, + channel_id: [u8; 16], + reuse: u32, + left: Address, + right: Address, + total: u64, + challenge_blocks: u64, + serial: u64, + left_balance: u64, + right_balance: u64, + ) -> Self { + Self { + network, + contract, + channel_id, + reuse, + left, + right, + total, + challenge_blocks, + serial, + left_balance, + right_balance, + left_sign: Sign::new(), + right_sign: Sign::new(), + } + } + + fn commitment(&self) -> Hash { + let mut bytes = + Vec::with_capacity(DOMAIN.len() + 32 + 21 + 16 + 4 + 21 + 21 + 8 + 8 + 8 + 8 + 8); + bytes.extend_from_slice(DOMAIN); + bytes.extend_from_slice(&self.network); + bytes.extend_from_slice(self.contract.as_bytes()); + bytes.extend_from_slice(&self.channel_id); + bytes.extend_from_slice(&self.reuse.to_be_bytes()); + bytes.extend_from_slice(self.left.as_bytes()); + bytes.extend_from_slice(self.right.as_bytes()); + bytes.extend_from_slice(&self.total.to_be_bytes()); + bytes.extend_from_slice(&self.challenge_blocks.to_be_bytes()); + bytes.extend_from_slice(&self.serial.to_be_bytes()); + bytes.extend_from_slice(&self.left_balance.to_be_bytes()); + bytes.extend_from_slice(&self.right_balance.to_be_bytes()); + Hash::from(sys::sha3(bytes)) + } + + fn sign(mut self, left: &Account, right: &Account) -> Self { + let hash = self.commitment(); + self.left_sign = Sign::create_by(left, &hash); + self.right_sign = Sign::create_by(right, &hash); + self + } +} + +struct ReferenceChannel { + network: [u8; 32], + contract: Address, + channel_id: [u8; 16], + reuse: u32, + left: Address, + right: Address, + total: u64, + serial: u64, + left_balance: u64, + right_balance: u64, + status: Status, +} + +impl ReferenceChannel { + fn validate_bill(&self, bill: &Bill) -> Result<(), ExitError> { + if bill.network != self.network + || bill.contract != self.contract + || bill.channel_id != self.channel_id + || bill.reuse != self.reuse + || bill.left != self.left + || bill.right != self.right + || bill.total != self.total + || bill.challenge_blocks != CHALLENGE_BLOCKS + { + return Err(ExitError::Binding); + } + if bill.serial <= self.serial { + return Err(ExitError::StaleSerial); + } + let Some(total) = bill.left_balance.checked_add(bill.right_balance) else { + return Err(ExitError::Conservation); + }; + if total != self.total { + return Err(ExitError::Conservation); + } + let hash = bill.commitment(); + if !verify_signature(&hash, &self.left, &bill.left_sign) + || !verify_signature(&hash, &self.right, &bill.right_sign) + { + return Err(ExitError::InvalidSignature); + } + Ok(()) + } + + fn accept_bill(&mut self, bill: &Bill) { + self.serial = bill.serial; + self.left_balance = bill.left_balance; + self.right_balance = bill.right_balance; + } + + fn challenge(&mut self, bill: &Bill, height: u64) -> Result<(), ExitError> { + if self.status != Status::Open { + return Err(ExitError::InvalidStatus); + } + self.validate_bill(bill)?; + let deadline = height + .checked_add(CHALLENGE_BLOCKS) + .ok_or(ExitError::HeightOverflow)?; + self.accept_bill(bill); + self.status = Status::Challenging { deadline }; + Ok(()) + } + + fn respond(&mut self, bill: &Bill, height: u64) -> Result<(), ExitError> { + let Status::Challenging { deadline } = self.status else { + return Err(ExitError::InvalidStatus); + }; + if height >= deadline { + return Err(ExitError::ChallengeExpired); + } + self.validate_bill(bill)?; + self.accept_bill(bill); + Ok(()) + } + + fn cooperative_close(&mut self, bill: &Bill) -> Result<(), ExitError> { + if self.status == Status::Final { + return Err(ExitError::InvalidStatus); + } + self.validate_bill(bill)?; + self.accept_bill(bill); + self.status = Status::Final; + Ok(()) + } + + fn finalize(&mut self, height: u64) -> Result<(u64, u64), ExitError> { + let Status::Challenging { deadline } = self.status else { + return Err(ExitError::InvalidStatus); + }; + if height < deadline { + return Err(ExitError::ChallengePending); + } + self.status = Status::Final; + Ok((self.left_balance, self.right_balance)) + } +} + +struct Fixture { + channel: ReferenceChannel, + left: Account, + right: Account, +} + +impl Fixture { + fn new(seed: u8) -> Self { + let left = Account::create_by(&format!("hpay-hvm-left-{seed}")).unwrap(); + let right = Account::create_by(&format!("hpay-hvm-right-{seed}")).unwrap(); + let deployer = Account::create_by(&format!("hpay-hvm-contract-{seed}")).unwrap(); + Self { + channel: ReferenceChannel { + network: [0x11; 32], + contract: Address::from(deployer.address().clone()), + channel_id: [seed; 16], + reuse: 7, + left: Address::from(left.address().clone()), + right: Address::from(right.address().clone()), + total: 1_000_000, + serial: 0, + left_balance: 600_000, + right_balance: 400_000, + status: Status::Open, + }, + left, + right, + } + } + + fn bill(&self, serial: u64, left_balance: u64, right_balance: u64) -> Bill { + Bill::unsigned( + self.channel.network, + self.channel.contract, + self.channel.channel_id, + self.channel.reuse, + self.channel.left, + self.channel.right, + self.channel.total, + CHALLENGE_BLOCKS, + serial, + left_balance, + right_balance, + ) + .sign(&self.left, &self.right) + } +} + +#[test] +fn hvm_contract_source_compiles_without_enabling_mainnet_capability() { + let output = + vm::fitshc::compile(CONTRACT_SOURCE).expect("prototype Fitsh contract must compile"); + let bytes = output.0.serialize(); + assert!(!bytes.is_empty()); + let manifest: serde_json::Value = serde_json::from_str(CONTRACT_MANIFEST).unwrap(); + let source_hash = hex::encode(Sha256::digest(CONTRACT_SOURCE.as_bytes())); + let storage_keys = manifest["storage_keys"].as_array().unwrap(); + let unique_storage_keys = storage_keys + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>(); + assert_eq!(manifest["schema"], "hpay-hvm-channel-exit-manifest/1"); + assert_eq!(manifest["contract_name"], "HPAYChannelExitV1"); + assert_eq!(manifest["protocol_domain"], "HPAY/HVM-CHANNEL/V1"); + assert_eq!(manifest["settlement_profile"], "hpay-hvm-channel-v1"); + assert_eq!(manifest["source_sha256"], source_hash); + assert_eq!(storage_keys.len(), 18); + assert_eq!(unique_storage_keys.len(), storage_keys.len()); + assert_eq!( + manifest["required_action_kinds"], + serde_json::json!([40, 41, 44]) + ); + assert_eq!(manifest["funding_model"]["left_deposit"], "positive"); + assert_eq!( + manifest["funding_model"]["right_hub_deposit"], + "exactly_zero" + ); + assert_eq!(manifest["lease_policy"]["permissionless_renewal"], true); + assert_eq!( + manifest["lease_policy"]["must_renew_every_storage_key"], + true + ); + assert_eq!(manifest["mainnet_deployment"]["enabled"], false); + assert_eq!( + manifest["mainnet_deployment"]["independently_verified"], + false + ); + assert!(manifest["mainnet_deployment"]["contract_address"].is_null()); + assert!(manifest["mainnet_deployment"]["deployment_tx_hash"].is_null()); + assert!(manifest["mainnet_deployment"]["deployment_height"].is_null()); + assert_eq!( + hex::encode(sys::sha3(bytes)), + "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349", + "review and pin the exact compiled contract commitment" + ); + assert_eq!( + manifest["bytecode_sha3"], + "11a2efc27a0c951bbc6977186eb58bd076dd331a785f3c57242cf54a72238349" + ); +} + +#[test] +fn higher_serial_response_wins_and_finalizes_after_deadline() { + let mut fixture = Fixture::new(1); + let first = fixture.bill(1, 550_000, 450_000); + fixture.channel.challenge(&first, 100).unwrap(); + let latest = fixture.bill(2, 525_000, 475_000); + fixture.channel.respond(&latest, 111).unwrap(); + assert_eq!( + fixture.channel.finalize(111), + Err(ExitError::ChallengePending) + ); + assert_eq!(fixture.channel.finalize(112), Ok((525_000, 475_000))); +} + +#[test] +fn stale_replay_and_double_finalize_are_rejected() { + let mut fixture = Fixture::new(2); + let bill = fixture.bill(1, 500_000, 500_000); + fixture.channel.challenge(&bill, 200).unwrap(); + assert_eq!( + fixture.channel.respond(&bill, 201), + Err(ExitError::StaleSerial) + ); + fixture.channel.finalize(212).unwrap(); + assert_eq!(fixture.channel.finalize(213), Err(ExitError::InvalidStatus)); + assert_eq!( + fixture + .channel + .cooperative_close(&fixture.bill(2, 400_000, 600_000)), + Err(ExitError::InvalidStatus) + ); +} + +#[test] +fn wrong_network_contract_channel_reuse_party_total_or_policy_is_rejected() { + let fixture = Fixture::new(3); + let mut cases = vec![]; + let mut wrong_network = fixture.bill(1, 500_000, 500_000); + wrong_network.network[0] ^= 1; + cases.push(wrong_network); + let mut wrong_contract = fixture.bill(1, 500_000, 500_000); + wrong_contract.contract = Address::from( + Account::create_by("hpay-wrong-contract") + .unwrap() + .address() + .clone(), + ); + cases.push(wrong_contract); + let mut wrong_channel = fixture.bill(1, 500_000, 500_000); + wrong_channel.channel_id[0] ^= 1; + cases.push(wrong_channel); + let mut wrong_reuse = fixture.bill(1, 500_000, 500_000); + wrong_reuse.reuse += 1; + cases.push(wrong_reuse); + let mut wrong_left = fixture.bill(1, 500_000, 500_000); + wrong_left.left = Address::from( + Account::create_by("hpay-wrong-left") + .unwrap() + .address() + .clone(), + ); + cases.push(wrong_left); + let mut wrong_right = fixture.bill(1, 500_000, 500_000); + wrong_right.right = Address::from( + Account::create_by("hpay-wrong-right") + .unwrap() + .address() + .clone(), + ); + cases.push(wrong_right); + let mut wrong_total = fixture.bill(1, 500_000, 500_000); + wrong_total.total += 1; + cases.push(wrong_total); + let mut wrong_policy = fixture.bill(1, 500_000, 500_000); + wrong_policy.challenge_blocks += 1; + cases.push(wrong_policy); + for bill in cases { + assert_eq!( + fixture.channel.validate_bill(&bill), + Err(ExitError::Binding) + ); + } +} + +#[test] +fn conservation_overflow_and_wrong_signer_are_rejected() { + let fixture = Fixture::new(4); + let non_conserving = fixture.bill(1, 500_000, 499_999); + assert_eq!( + fixture.channel.validate_bill(&non_conserving), + Err(ExitError::Conservation) + ); + let overflow = fixture.bill(1, u64::MAX, 1); + assert_eq!( + fixture.channel.validate_bill(&overflow), + Err(ExitError::Conservation) + ); + let attacker = Account::create_by("hpay-hvm-attacker").unwrap(); + let mut bad_signature = fixture.bill(1, 500_000, 500_000); + bad_signature.left_sign = Sign::create_by(&attacker, &bad_signature.commitment()); + assert_eq!( + fixture.channel.validate_bill(&bad_signature), + Err(ExitError::InvalidSignature) + ); +} + +#[test] +fn response_after_deadline_and_height_overflow_are_rejected() { + let mut fixture = Fixture::new(5); + let first = fixture.bill(1, 500_000, 500_000); + fixture.channel.challenge(&first, 300).unwrap(); + let response = fixture.bill(2, 450_000, 550_000); + assert_eq!( + fixture.channel.respond(&response, 312), + Err(ExitError::ChallengeExpired) + ); + + let mut overflow_fixture = Fixture::new(6); + let bill = overflow_fixture.bill(1, 500_000, 500_000); + assert_eq!( + overflow_fixture.channel.challenge(&bill, u64::MAX), + Err(ExitError::HeightOverflow) + ); + assert_eq!(overflow_fixture.channel.status, Status::Open); +} + +#[test] +fn cooperative_close_uses_exact_signed_state() { + let mut fixture = Fixture::new(7); + let bill = fixture.bill(1, 375_000, 625_000); + fixture.channel.cooperative_close(&bill).unwrap(); + assert_eq!(fixture.channel.status, Status::Final); + assert_eq!(fixture.channel.left_balance, 375_000); + assert_eq!(fixture.channel.right_balance, 625_000); + assert_eq!( + fixture + .channel + .cooperative_close(&fixture.bill(2, 300_000, 700_000)), + Err(ExitError::InvalidStatus) + ); +} + +fn account_address(account: &Account) -> Address { + Address::from(account.address().clone()) +} + +fn contract_call_source(contract: &ContractAddress, call: &str) -> String { + format!( + "lib Channel = 1: {}\nvar result = Channel.{}\nassert result == 0\nend", + contract.to_readable(), + call + ) +} + +fn renew_all_source(contract: &ContractAddress, periods: u64) -> String { + let manifest: serde_json::Value = serde_json::from_str(CONTRACT_MANIFEST).unwrap(); + let keys = manifest["storage_keys"].as_array().unwrap(); + let mut source = format!("lib Channel = 1: {}\n", contract.to_readable()); + for (index, key) in keys.iter().enumerate() { + let key = key.as_str().unwrap(); + source.push_str(&format!( + r#"var renewal_{index} = Channel.renew("{key}", {periods}) +assert renewal_{index} == 0 +"# + )); + } + source.push_str("end"); + source +} + +fn confirm_source( + chain: &mut MemChain, + payer: &Account, + extra_signers: &[&Account], + contract: &ContractAddress, + source: &str, + miner: Address, +) { + let payer_address = account_address(payer); + let mut addrs = vec![payer_address, contract.to_addr()]; + addrs.extend(extra_signers.iter().map(|account| account_address(account))); + let hash = chain + .submit_formal_main_call_fitsh_with_signers(payer, extra_signers, addrs, source, u8::MAX) + .expect("build signed formal HVM source call"); + chain + .confirm_formal_block(miner) + .expect("execute formal HVM source block") + .expect_success(&hash); +} + +fn confirm_call( + chain: &mut MemChain, + payer: &Account, + extra_signers: &[&Account], + contract: &ContractAddress, + call: &str, + miner: Address, +) { + confirm_source( + chain, + payer, + extra_signers, + contract, + &contract_call_source(contract, call), + miner, + ); +} + +fn confirm_deposit( + chain: &mut MemChain, + payer: &Account, + contract: &ContractAddress, + amount: u64, + miner: Address, +) { + let payer_address = account_address(payer); + let mut action = HacToTrs::new(); + action.to = AddrOrPtr::from_addr(contract.to_addr()); + action.hacash = Amount::zhu(amount); + let hash = chain + .submit_formal_actions( + payer, + vec![payer_address, contract.to_addr()], + vec![Box::new(action)], + u8::MAX, + TxOutput::None, + ) + .expect("build signed formal deposit"); + chain + .confirm_formal_block(miner) + .expect("execute formal deposit block") + .expect_success(&hash); +} + +fn submit_payout( + chain: &mut MemChain, + payer: &Account, + contract: &ContractAddress, + recipient: Address, + amount: u64, +) -> Hash { + let payer_address = account_address(payer); + let mut action = HacFromToTrs::new(); + action.from = AddrOrPtr::from_addr(contract.to_addr()); + action.to = AddrOrPtr::from_addr(recipient); + action.hacash = Amount::zhu(amount); + chain + .submit_formal_actions( + payer, + vec![payer_address, contract.to_addr(), recipient], + vec![Box::new(action)], + u8::MAX, + TxOutput::None, + ) + .expect("build signed formal payout") +} + +#[test] +fn private_chain_executes_storage_challenge_and_one_time_payout_hooks() { + const LEFT_DEPOSIT: u64 = 600_000; + const RIGHT_DEPOSIT: u64 = 0; + const LEFT_FINAL: u64 = 300_000; + const RIGHT_FINAL: u64 = 300_000; + + let mut chain = MemChain::new(); + chain.set_height(protocol::upgrade::ONLINE_OPEN_HEIGHT); + let deployer = Account::create_by("hpay-hvm-e2e-deployer").unwrap(); + let left = Account::create_by("hpay-hvm-e2e-left").unwrap(); + let right = Account::create_by("hpay-hvm-e2e-right").unwrap(); + let watchtower = Account::create_by("hpay-hvm-e2e-watchtower").unwrap(); + let miner = account_address(&Account::create_by("hpay-hvm-e2e-miner").unwrap()); + let left_address = account_address(&left); + let right_address = account_address(&right); + let watchtower_address = account_address(&watchtower); + for (address, amount) in [ + (account_address(&deployer), 20_000_000_000_000_u64), + (left_address, 10_000_000_000_000), + (right_address, 10_000_000_000_000), + (watchtower_address, 10_000_000_000_000), + ] { + chain.mint_hac(&address, amount); + } + + let compiled = vm::fitshc::compile(CONTRACT_SOURCE).unwrap().0.into_sto(); + let deployer_address = account_address(&deployer); + let contract = ContractAddress::calculate(&deployer_address, &Uint4::from(0)); + let mut deploy = vm::action::ContractDeploy::new(); + deploy.nonce = Uint4::from(0); + deploy.contract = compiled; + deploy.protocol_cost = Amount::unit238(2_000_000_000_000); + let deploy_hash = chain + .submit_formal_actions( + &deployer, + vec![deployer_address], + vec![Box::new(deploy)], + u8::MAX, + TxOutput::ContractAddress(contract.clone()), + ) + .expect("build formal contract deployment"); + chain + .confirm_formal_block(miner) + .expect("execute deployment block") + .expect_success(&deploy_hash); + + let network = [0x11_u8; 32]; + let channel_id = [0x42_u8; 16]; + let invalid_hub_funded_init = format!( + "init(0x{}, 0x{}, 7, {}, {}, {}, 1, {}, 100)", + hex::encode(network), + hex::encode(channel_id), + left_address.to_readable(), + right_address.to_readable(), + LEFT_DEPOSIT, + CHALLENGE_BLOCKS, + ); + let invalid_init_hash = chain + .submit_formal_main_call_fitsh_with_signers( + &left, + &[&right], + vec![left_address, contract.to_addr(), right_address], + &contract_call_source(&contract, &invalid_hub_funded_init), + u8::MAX, + ) + .expect("build invalid Hub-funded init"); + let invalid_init_block = chain + .confirm_formal_block_observing_failures(miner) + .expect("execute invalid Hub-funded init block"); + assert!( + invalid_init_block + .receipt(&invalid_init_hash) + .expect("invalid init receipt") + .is_error(), + "a non-zero Hub principal must never initialize an HPAY HVM channel" + ); + + let init = format!( + "init(0x{}, 0x{}, 7, {}, {}, {}, {}, {}, 100)", + hex::encode(network), + hex::encode(channel_id), + left_address.to_readable(), + right_address.to_readable(), + LEFT_DEPOSIT, + RIGHT_DEPOSIT, + CHALLENGE_BLOCKS, + ); + confirm_call(&mut chain, &left, &[&right], &contract, &init, miner); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(1) + ); + confirm_source( + &mut chain, + &watchtower, + &[], + &contract, + &renew_all_source(&contract, 100), + miner, + ); + let manifest: serde_json::Value = serde_json::from_str(CONTRACT_MANIFEST).unwrap(); + for key in manifest["storage_keys"].as_array().unwrap() { + let key = key.as_str().unwrap(); + assert_ne!( + chain.storage(&contract, &Value::bytes(key.as_bytes().to_vec())), + Value::Nil, + "{key} must remain active after atomic renewal" + ); + } + + confirm_deposit(&mut chain, &left, &contract, LEFT_DEPOSIT, miner); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(2) + ); + assert_eq!( + chain.balance(&contract.to_addr()).to_zhu_u64(), + Ok(LEFT_DEPOSIT + RIGHT_DEPOSIT) + ); + + let first = Bill::unsigned( + network, + contract.to_addr(), + channel_id, + 7, + left_address, + right_address, + LEFT_DEPOSIT + RIGHT_DEPOSIT, + CHALLENGE_BLOCKS, + 1, + 450_000, + 150_000, + ) + .sign(&left, &right); + let challenge = format!( + "challenge({}, {}, {}, 0x{}, 0x{})", + first.serial, + first.left_balance, + first.right_balance, + hex::encode(first.left_sign.serialize()), + hex::encode(first.right_sign.serialize()), + ); + let before_challenge = chain.snapshot(); + confirm_call(&mut chain, &watchtower, &[], &contract, &challenge, miner); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(3) + ); + chain.restore(before_challenge); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(2) + ); + confirm_call(&mut chain, &watchtower, &[], &contract, &challenge, miner); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(3) + ); + + let latest = Bill::unsigned( + network, + contract.to_addr(), + channel_id, + 7, + left_address, + right_address, + LEFT_DEPOSIT + RIGHT_DEPOSIT, + CHALLENGE_BLOCKS, + 2, + LEFT_FINAL, + RIGHT_FINAL, + ) + .sign(&left, &right); + let respond = format!( + "respond({}, {}, {}, 0x{}, 0x{})", + latest.serial, + latest.left_balance, + latest.right_balance, + hex::encode(latest.left_sign.serialize()), + hex::encode(latest.right_sign.serialize()), + ); + confirm_call(&mut chain, &watchtower, &[], &contract, &respond, miner); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"serial".to_vec())), + Value::U64(2) + ); + let Value::U64(deadline) = chain.storage(&contract, &Value::bytes(b"deadline".to_vec())) else { + panic!("challenge deadline must be stored as u64") + }; + chain + .confirm_empty_formal_blocks_to_height(miner, deadline.saturating_sub(1)) + .unwrap(); + confirm_call(&mut chain, &watchtower, &[], &contract, "finalize()", miner); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(4) + ); + + let left_before = chain.balance(&left_address).to_zhu_u64().unwrap(); + let right_before = chain.balance(&right_address).to_zhu_u64().unwrap(); + let left_payout = submit_payout(&mut chain, &watchtower, &contract, left_address, LEFT_FINAL); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&left_payout); + + // The contract still holds enough HAC for this replay, so rejection proves + // the durable claimed flag is enforced by PermitHAC rather than merely + // relying on an empty contract balance. + let replay = submit_payout(&mut chain, &watchtower, &contract, left_address, LEFT_FINAL); + let failed = chain + .confirm_formal_block_observing_failures(miner) + .unwrap(); + failed + .receipt(&replay) + .expect("replayed payout receipt") + .expect_error_contains("HPAY_LEFT_ALREADY_CLAIMED"); + assert_eq!( + chain.balance(&left_address).to_zhu_u64(), + Ok(left_before + LEFT_FINAL) + ); + + let right_payout = submit_payout( + &mut chain, + &watchtower, + &contract, + right_address, + RIGHT_FINAL, + ); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&right_payout); + assert_eq!( + chain.balance(&left_address).to_zhu_u64(), + Ok(left_before + LEFT_FINAL) + ); + assert_eq!( + chain.balance(&right_address).to_zhu_u64(), + Ok(right_before + RIGHT_FINAL) + ); + assert_eq!(chain.balance(&contract.to_addr()).to_zhu_u64(), Ok(0)); + + // Value growth can consume lease credit at different rates. Move far + // enough that at least one key is recoverable, but not absent, then prove + // a third party can atomically restore all 18 without either channel key. + let base_height = chain.height(); + chain.set_height(base_height.saturating_add(16_000)); + let mut recoverable_keys = 0usize; + for key in manifest["storage_keys"].as_array().unwrap() { + let key = key.as_str().unwrap(); + let debug = VMStateRead::wrap(chain.state()) + .debug_storage_get( + &vm::rt::GasExtra::new(chain.height()), + &vm::rt::SpaceCap::new(chain.height()), + chain.height(), + &contract.to_addr(), + &Value::bytes(key.as_bytes().to_vec()), + ) + .unwrap(); + let debug = debug.unwrap_or_else(|| panic!("{key} must still be recoverable")); + recoverable_keys += usize::from(debug.recoverable); + } + assert!( + recoverable_keys > 0, + "the recovery fixture must contain at least one recoverable lease" + ); + confirm_source( + &mut chain, + &watchtower, + &[], + &contract, + &renew_all_source(&contract, 100), + miner, + ); + for key in manifest["storage_keys"].as_array().unwrap() { + let key = key.as_str().unwrap(); + assert_ne!( + chain.storage(&contract, &Value::bytes(key.as_bytes().to_vec())), + Value::Nil, + "{key} must be restored by the atomic recovery renewal" + ); + } + assert_eq!( + chain.storage(&contract, &Value::bytes(b"status".to_vec())), + Value::U8(4) + ); +} diff --git a/vm/tests/hpay_channel_registry.rs b/vm/tests/hpay_channel_registry.rs new file mode 100644 index 00000000..b657240f --- /dev/null +++ b/vm/tests/hpay_channel_registry.rs @@ -0,0 +1,927 @@ +use field::{AddrOrPtr, Address, Amount, BytesW2, Field, Hash, Serialize, Sign, Uint4}; +use protocol::action::{HacFromToTrs, HacToTrs}; +use sha2::{Digest, Sha256}; +use sys::Account; +use testkit::sim::memchain::{MemChain, TxOutput}; +use vm::value::Value; +use vm::{ContractAddress, VMStateRead}; + +const DOMAIN: &[u8] = b"HPAY/HVM-CHANNEL-REGISTRY/V2"; +const CHALLENGE_BLOCKS: u64 = 12; +const CONTRACT_SOURCE: &str = include_str!("../contracts/hpay_channel_registry_v2.fitsh"); +const CONTRACT_MANIFEST: &str = include_str!("../contracts/hpay_channel_registry_v2.manifest.json"); + +fn account_address(account: &Account) -> Address { + Address::from(account.address().clone()) +} + +fn channel_key(prefix: &str, left: &Address) -> Value { + let mut key = prefix.as_bytes().to_vec(); + key.extend_from_slice(left.as_bytes()); + Value::bytes(key) +} + +#[derive(Clone)] +struct Bill { + network: [u8; 32], + contract: Address, + channel_id: [u8; 16], + reuse: u32, + left: Address, + hub: Address, + total: u64, + challenge_blocks: u64, + serial: u64, + left_balance: u64, + hub_balance: u64, + left_sign: Sign, + hub_sign: Sign, +} + +impl Bill { + fn unsigned( + network: [u8; 32], + contract: Address, + channel_id: [u8; 16], + reuse: u32, + left: Address, + hub: Address, + total: u64, + serial: u64, + left_balance: u64, + hub_balance: u64, + ) -> Self { + Self { + network, + contract, + channel_id, + reuse, + left, + hub, + total, + challenge_blocks: CHALLENGE_BLOCKS, + serial, + left_balance, + hub_balance, + left_sign: Sign::new(), + hub_sign: Sign::new(), + } + } + + fn commitment(&self) -> Hash { + let mut bytes = Vec::new(); + bytes.extend_from_slice(DOMAIN); + bytes.extend_from_slice(&self.network); + bytes.extend_from_slice(self.contract.as_bytes()); + bytes.extend_from_slice(&self.channel_id); + bytes.extend_from_slice(&self.reuse.to_be_bytes()); + bytes.extend_from_slice(self.left.as_bytes()); + bytes.extend_from_slice(self.hub.as_bytes()); + bytes.extend_from_slice(&self.total.to_be_bytes()); + bytes.extend_from_slice(&self.challenge_blocks.to_be_bytes()); + bytes.extend_from_slice(&self.serial.to_be_bytes()); + bytes.extend_from_slice(&self.left_balance.to_be_bytes()); + bytes.extend_from_slice(&self.hub_balance.to_be_bytes()); + Hash::from(sys::sha3(bytes)) + } + + fn sign(mut self, left: &Account, hub: &Account) -> Self { + let commitment = self.commitment(); + self.left_sign = Sign::create_by(left, &commitment); + self.hub_sign = Sign::create_by(hub, &commitment); + self + } +} + +fn contract_call_source(contract: &ContractAddress, call: &str) -> String { + format!( + "lib Registry = 1: {}\nvar result = Registry.{}\nassert result == 0\nend", + contract.to_readable(), + call + ) +} + +fn confirm_source( + chain: &mut MemChain, + payer: &Account, + extra_signers: &[&Account], + contract: &ContractAddress, + source: &str, + miner: Address, +) { + let mut addrs = vec![account_address(payer), contract.to_addr()]; + addrs.extend(extra_signers.iter().map(|account| account_address(account))); + let hash = chain + .submit_formal_main_call_fitsh_with_signers(payer, extra_signers, addrs, source, u8::MAX) + .expect("build signed registry call"); + chain + .confirm_formal_block(miner) + .expect("execute registry call block") + .expect_success(&hash); +} + +fn confirm_call( + chain: &mut MemChain, + payer: &Account, + extra_signers: &[&Account], + contract: &ContractAddress, + call: &str, + miner: Address, +) { + confirm_source( + chain, + payer, + extra_signers, + contract, + &contract_call_source(contract, call), + miner, + ); +} + +fn confirm_deposit( + chain: &mut MemChain, + payer: &Account, + contract: &ContractAddress, + amount: u64, + miner: Address, +) { + let payer_address = account_address(payer); + let mut action = HacToTrs::new(); + action.to = AddrOrPtr::from_addr(contract.to_addr()); + action.hacash = Amount::zhu(amount); + let hash = chain + .submit_formal_actions( + payer, + vec![payer_address, contract.to_addr()], + vec![Box::new(action)], + u8::MAX, + TxOutput::None, + ) + .expect("build registry deposit"); + chain + .confirm_formal_block(miner) + .expect("execute registry deposit") + .expect_success(&hash); +} + +fn submit_payout( + chain: &mut MemChain, + payer: &Account, + contract: &ContractAddress, + recipient: Address, + amount: u64, +) -> Hash { + let payer_address = account_address(payer); + let mut action = HacFromToTrs::new(); + action.from = AddrOrPtr::from_addr(contract.to_addr()); + action.to = AddrOrPtr::from_addr(recipient); + action.hacash = Amount::zhu(amount); + chain + .submit_formal_actions( + payer, + vec![payer_address, contract.to_addr(), recipient], + vec![Box::new(action)], + u8::MAX, + TxOutput::None, + ) + .expect("build registry payout") +} + +fn submit_call( + chain: &mut MemChain, + payer: &Account, + extra_signers: &[&Account], + contract: &ContractAddress, + call: &str, +) -> Hash { + let mut addrs = vec![account_address(payer), contract.to_addr()]; + addrs.extend(extra_signers.iter().map(|account| account_address(account))); + chain + .submit_formal_main_call_fitsh_with_signers( + payer, + extra_signers, + addrs, + &contract_call_source(contract, call), + u8::MAX, + ) + .expect("build registry call") +} + +fn confirm_call_failure( + chain: &mut MemChain, + payer: &Account, + extra_signers: &[&Account], + contract: &ContractAddress, + call: &str, + miner: Address, +) { + let hash = submit_call(chain, payer, extra_signers, contract, call); + let block = chain + .confirm_formal_block_observing_failures(miner) + .expect("execute expected-failure registry call"); + assert!( + block + .receipt(&hash) + .expect("expected-failure registry receipt") + .is_error(), + "registry call unexpectedly succeeded: {call}" + ); +} + +fn bill_call(name: &str, bill: &Bill) -> String { + format!( + "{}({}, {}, {}, {}, 0x{}, 0x{})", + name, + bill.left.to_readable(), + bill.serial, + bill.left_balance, + bill.hub_balance, + hex::encode(bill.left_sign.serialize()), + hex::encode(bill.hub_sign.serialize()), + ) +} + +struct RegistryFixture { + chain: MemChain, + hub: Account, + left: Account, + watchtower: Account, + miner: Address, + network: [u8; 32], + contract: ContractAddress, +} + +impl RegistryFixture { + fn new(seed: &str) -> Self { + let mut chain = MemChain::new(); + chain.set_height(protocol::upgrade::ONLINE_OPEN_HEIGHT); + let hub = Account::create_by(&format!("hpay-registry-hub-{seed}")).unwrap(); + let left = Account::create_by(&format!("hpay-registry-left-{seed}")).unwrap(); + let watchtower = Account::create_by(&format!("hpay-registry-watchtower-{seed}")).unwrap(); + let miner = + account_address(&Account::create_by(&format!("hpay-registry-miner-{seed}")).unwrap()); + let hub_address = account_address(&hub); + for address in [ + hub_address, + account_address(&left), + account_address(&watchtower), + ] { + chain.mint_hac(&address, 30_000_000_000_000); + } + let network = [0x33_u8; 32]; + let contract = ContractAddress::calculate(&hub_address, &Uint4::from(0)); + let mut deploy = vm::action::ContractDeploy::new(); + deploy.nonce = Uint4::from(0); + deploy.construct_argv = BytesW2::from(network.to_vec()).unwrap(); + deploy.contract = vm::fitshc::compile(CONTRACT_SOURCE).unwrap().0.into_sto(); + deploy.protocol_cost = Amount::unit238(2_000_000_000_000); + let hash = chain + .submit_formal_actions( + &hub, + vec![hub_address], + vec![Box::new(deploy)], + u8::MAX, + TxOutput::ContractAddress(contract.clone()), + ) + .unwrap(); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&hash); + Self { + chain, + hub, + left, + watchtower, + miner, + network, + contract, + } + } + + fn init_call(&self, channel_id: [u8; 16], reuse: u32, deposit: u64) -> String { + format!( + "init(0x{}, {}, {}, {}, {}, 100)", + hex::encode(channel_id), + reuse, + account_address(&self.left).to_readable(), + deposit, + CHALLENGE_BLOCKS, + ) + } +} + +#[test] +fn shared_registry_source_compiles_and_constructor_is_present() { + let output = vm::fitshc::compile(CONTRACT_SOURCE).expect("registry v2 must compile"); + let bytes = output.0.serialize(); + assert!(!bytes.is_empty()); + let source_hash = hex::encode(Sha256::digest(CONTRACT_SOURCE.as_bytes())); + let bytecode_hash = hex::encode(sys::sha3(bytes)); + let manifest: serde_json::Value = serde_json::from_str(CONTRACT_MANIFEST).unwrap(); + assert_eq!(manifest["schema"], "hpay-hvm-channel-registry-manifest/2"); + assert_eq!(manifest["contract_name"], "HPAYChannelRegistryV2"); + assert_eq!(manifest["protocol_domain"], "HPAY/HVM-CHANNEL-REGISTRY/V2"); + assert_eq!(manifest["source_sha256"], source_hash); + assert_eq!(manifest["bytecode_sha3"], bytecode_hash); + assert_eq!( + manifest["required_action_kinds"], + serde_json::json!([40, 41, 44]) + ); + assert_eq!( + manifest["deployment_model"]["per_channel_contract_deploy"], + false + ); + assert_eq!( + manifest["channel_model"]["right_hub_deposit"], + "exactly_zero" + ); + assert_eq!( + manifest["registry_storage_keys"].as_array().unwrap().len(), + 6 + ); + assert_eq!( + manifest["channel_storage_prefixes"] + .as_array() + .unwrap() + .len(), + 12 + ); + assert_eq!(manifest["mainnet_deployment"]["enabled"], false); + assert_eq!( + manifest["mainnet_deployment"]["external_audit_complete"], + false + ); +} + +#[test] +fn one_deployment_isolates_two_channels_and_aggregates_hub_claims() { + const A_DEPOSIT: u64 = 1_000_000; + const B_DEPOSIT: u64 = 2_000_000; + const A_LEFT_FINAL: u64 = 700_000; + const A_HUB_FINAL: u64 = 300_000; + const B_LEFT_FINAL: u64 = 1_250_000; + const B_HUB_FINAL: u64 = 750_000; + + let mut chain = MemChain::new(); + chain.set_height(protocol::upgrade::ONLINE_OPEN_HEIGHT); + let hub = Account::create_by("hpay-registry-v2-hub").unwrap(); + let left_a = Account::create_by("hpay-registry-v2-left-a").unwrap(); + let left_b = Account::create_by("hpay-registry-v2-left-b").unwrap(); + let watchtower = Account::create_by("hpay-registry-v2-watchtower").unwrap(); + let miner = account_address(&Account::create_by("hpay-registry-v2-miner").unwrap()); + let hub_address = account_address(&hub); + let left_a_address = account_address(&left_a); + let left_b_address = account_address(&left_b); + let watchtower_address = account_address(&watchtower); + for (address, amount) in [ + (hub_address, 30_000_000_000_000_u64), + (left_a_address, 10_000_000_000_000), + (left_b_address, 10_000_000_000_000), + (watchtower_address, 10_000_000_000_000), + ] { + chain.mint_hac(&address, amount); + } + + let network = [0x22_u8; 32]; + let compiled = vm::fitshc::compile(CONTRACT_SOURCE).unwrap().0.into_sto(); + let contract = ContractAddress::calculate(&hub_address, &Uint4::from(0)); + let mut deploy = vm::action::ContractDeploy::new(); + deploy.nonce = Uint4::from(0); + deploy.construct_argv = BytesW2::from(network.to_vec()).unwrap(); + deploy.contract = compiled; + deploy.protocol_cost = Amount::unit238(2_000_000_000_000); + let deploy_hash = chain + .submit_formal_actions( + &hub, + vec![hub_address], + vec![Box::new(deploy)], + u8::MAX, + TxOutput::ContractAddress(contract.clone()), + ) + .expect("build shared registry deployment"); + chain + .confirm_formal_block(miner) + .expect("execute shared registry deployment") + .expect_success(&deploy_hash); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_network".to_vec())), + Value::bytes(network.to_vec()) + ); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_hub".to_vec())), + Value::Address(hub_address) + ); + + let channel_a = [0xA1_u8; 16]; + let channel_b = [0xB2_u8; 16]; + let init_a = format!( + "init(0x{}, 0, {}, {}, {}, 100)", + hex::encode(channel_a), + left_a_address.to_readable(), + A_DEPOSIT, + CHALLENGE_BLOCKS, + ); + let init_b = format!( + "init(0x{}, 0, {}, {}, {}, 100)", + hex::encode(channel_b), + left_b_address.to_readable(), + B_DEPOSIT, + CHALLENGE_BLOCKS, + ); + confirm_call(&mut chain, &left_a, &[&hub], &contract, &init_a, miner); + confirm_call(&mut chain, &left_b, &[&hub], &contract, &init_b, miner); + confirm_deposit(&mut chain, &left_a, &contract, A_DEPOSIT, miner); + confirm_deposit(&mut chain, &left_b, &contract, B_DEPOSIT, miner); + + assert_eq!( + chain.storage(&contract, &channel_key("c_status_", &left_a_address)), + Value::U8(2) + ); + assert_eq!( + chain.storage(&contract, &channel_key("c_status_", &left_b_address)), + Value::U8(2) + ); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_locked".to_vec())), + Value::U64(A_DEPOSIT + B_DEPOSIT) + ); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_open_count".to_vec())), + Value::U64(2) + ); + + let a_stale = Bill::unsigned( + network, + contract.to_addr(), + channel_a, + 0, + left_a_address, + hub_address, + A_DEPOSIT, + 1, + 800_000, + 200_000, + ) + .sign(&left_a, &hub); + confirm_call( + &mut chain, + &watchtower, + &[], + &contract, + &bill_call("challenge", &a_stale), + miner, + ); + + let b_final = Bill::unsigned( + network, + contract.to_addr(), + channel_b, + 0, + left_b_address, + hub_address, + B_DEPOSIT, + 1, + B_LEFT_FINAL, + B_HUB_FINAL, + ) + .sign(&left_b, &hub); + confirm_call( + &mut chain, + &watchtower, + &[], + &contract, + &bill_call("cooperative_close", &b_final), + miner, + ); + assert_eq!( + chain.storage(&contract, &channel_key("c_status_", &left_a_address)), + Value::U8(3), + "closing channel B must not change channel A" + ); + assert_eq!( + chain.storage(&contract, &channel_key("c_status_", &left_b_address)), + Value::U8(4) + ); + + let mut cross_channel = a_stale.clone(); + cross_channel.left = left_b_address; + let cross_hash = submit_call( + &mut chain, + &watchtower, + &[], + &contract, + &bill_call("respond", &cross_channel), + ); + assert!( + chain + .confirm_formal_block_observing_failures(miner) + .expect("execute cross-channel replay block") + .receipt(&cross_hash) + .expect("cross-channel replay receipt") + .is_error() + ); + assert_eq!( + chain.storage(&contract, &channel_key("c_serial_", &left_b_address)), + Value::U64(1), + "cross-channel replay must not mutate channel B" + ); + + let a_latest = Bill::unsigned( + network, + contract.to_addr(), + channel_a, + 0, + left_a_address, + hub_address, + A_DEPOSIT, + 2, + A_LEFT_FINAL, + A_HUB_FINAL, + ) + .sign(&left_a, &hub); + confirm_call( + &mut chain, + &watchtower, + &[], + &contract, + &bill_call("respond", &a_latest), + miner, + ); + let Value::U64(a_deadline) = + chain.storage(&contract, &channel_key("c_deadline_", &left_a_address)) + else { + panic!("channel A deadline must be u64") + }; + chain + .confirm_empty_formal_blocks_to_height(miner, a_deadline) + .unwrap(); + confirm_call( + &mut chain, + &watchtower, + &[], + &contract, + &format!("finalize({})", left_a_address.to_readable()), + miner, + ); + + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_locked".to_vec())), + Value::U64(0) + ); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_open_count".to_vec())), + Value::U64(0) + ); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_left_claimable".to_vec())), + Value::U64(A_LEFT_FINAL + B_LEFT_FINAL) + ); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_hub_claimable".to_vec())), + Value::U64(A_HUB_FINAL + B_HUB_FINAL) + ); + + let left_a_payout = submit_payout( + &mut chain, + &watchtower, + &contract, + left_a_address, + A_LEFT_FINAL, + ); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&left_a_payout); + let replay = submit_payout( + &mut chain, + &watchtower, + &contract, + left_a_address, + A_LEFT_FINAL, + ); + chain + .confirm_formal_block_observing_failures(miner) + .unwrap() + .receipt(&replay) + .expect("left replay receipt") + .expect_error_contains("HPAY_LEFT_ALREADY_CLAIMED"); + let left_b_payout = submit_payout( + &mut chain, + &watchtower, + &contract, + left_b_address, + B_LEFT_FINAL, + ); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&left_b_payout); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_left_claimable".to_vec())), + Value::U64(0) + ); + + let first_hub_claim = + submit_payout(&mut chain, &watchtower, &contract, hub_address, A_HUB_FINAL); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&first_hub_claim); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_hub_claimable".to_vec())), + Value::U64(B_HUB_FINAL) + ); + let second_hub_claim = + submit_payout(&mut chain, &watchtower, &contract, hub_address, B_HUB_FINAL); + chain + .confirm_formal_block(miner) + .unwrap() + .expect_success(&second_hub_claim); + assert_eq!( + chain.storage(&contract, &Value::bytes(b"g_hub_claimable".to_vec())), + Value::U64(0) + ); + assert_eq!(chain.balance(&contract.to_addr()).to_zhu_u64(), Ok(0)); +} + +#[test] +fn init_requires_both_parties_and_reuse_invalidates_old_bills() { + const DEPOSIT: u64 = 900_000; + let mut fixture = RegistryFixture::new("reuse"); + let left_address = account_address(&fixture.left); + let hub_address = account_address(&fixture.hub); + let first_id = [0x41_u8; 16]; + let first_init = fixture.init_call(first_id, 0, DEPOSIT); + + confirm_call_failure( + &mut fixture.chain, + &fixture.left, + &[], + &fixture.contract, + &first_init, + fixture.miner, + ); + confirm_call_failure( + &mut fixture.chain, + &fixture.hub, + &[], + &fixture.contract, + &first_init, + fixture.miner, + ); + assert_eq!( + fixture + .chain + .storage(&fixture.contract, &channel_key("c_status_", &left_address)), + Value::Nil + ); + + confirm_call( + &mut fixture.chain, + &fixture.left, + &[&fixture.hub], + &fixture.contract, + &first_init, + fixture.miner, + ); + let active_reinit = fixture.init_call([0x42; 16], 1, DEPOSIT); + confirm_call_failure( + &mut fixture.chain, + &fixture.left, + &[&fixture.hub], + &fixture.contract, + &active_reinit, + fixture.miner, + ); + confirm_deposit( + &mut fixture.chain, + &fixture.left, + &fixture.contract, + DEPOSIT, + fixture.miner, + ); + + let final_bill = Bill::unsigned( + fixture.network, + fixture.contract.to_addr(), + first_id, + 0, + left_address, + hub_address, + DEPOSIT, + 1, + DEPOSIT, + 0, + ) + .sign(&fixture.left, &fixture.hub); + confirm_call( + &mut fixture.chain, + &fixture.watchtower, + &[], + &fixture.contract, + &bill_call("cooperative_close", &final_bill), + fixture.miner, + ); + let unclaimed_reinit = fixture.init_call([0x42; 16], 1, DEPOSIT); + confirm_call_failure( + &mut fixture.chain, + &fixture.left, + &[&fixture.hub], + &fixture.contract, + &unclaimed_reinit, + fixture.miner, + ); + + let payout = submit_payout( + &mut fixture.chain, + &fixture.watchtower, + &fixture.contract, + left_address, + DEPOSIT, + ); + fixture + .chain + .confirm_formal_block(fixture.miner) + .unwrap() + .expect_success(&payout); + let skipped_reuse = fixture.init_call([0x42; 16], 2, DEPOSIT); + confirm_call_failure( + &mut fixture.chain, + &fixture.left, + &[&fixture.hub], + &fixture.contract, + &skipped_reuse, + fixture.miner, + ); + + let second_id = [0x42_u8; 16]; + let second_init = fixture.init_call(second_id, 1, DEPOSIT); + confirm_call( + &mut fixture.chain, + &fixture.left, + &[&fixture.hub], + &fixture.contract, + &second_init, + fixture.miner, + ); + confirm_deposit( + &mut fixture.chain, + &fixture.left, + &fixture.contract, + DEPOSIT, + fixture.miner, + ); + + let old_generation = Bill::unsigned( + fixture.network, + fixture.contract.to_addr(), + first_id, + 0, + left_address, + hub_address, + DEPOSIT, + 2, + 800_000, + 100_000, + ) + .sign(&fixture.left, &fixture.hub); + confirm_call_failure( + &mut fixture.chain, + &fixture.watchtower, + &[], + &fixture.contract, + &bill_call("challenge", &old_generation), + fixture.miner, + ); + assert_eq!( + fixture + .chain + .storage(&fixture.contract, &channel_key("c_serial_", &left_address)), + Value::U64(0) + ); + assert_eq!( + fixture + .chain + .storage(&fixture.contract, &channel_key("c_status_", &left_address)), + Value::U8(2) + ); +} + +#[test] +fn permissionless_renewal_recovers_registry_and_exact_channel_keys() { + const DEPOSIT: u64 = 750_000; + let mut fixture = RegistryFixture::new("leases"); + let left_address = account_address(&fixture.left); + let init = fixture.init_call([0x51; 16], 0, DEPOSIT); + confirm_call( + &mut fixture.chain, + &fixture.left, + &[&fixture.hub], + &fixture.contract, + &init, + fixture.miner, + ); + confirm_deposit( + &mut fixture.chain, + &fixture.left, + &fixture.contract, + DEPOSIT, + fixture.miner, + ); + confirm_call( + &mut fixture.chain, + &fixture.watchtower, + &[], + &fixture.contract, + "renew_registry(100)", + fixture.miner, + ); + confirm_call( + &mut fixture.chain, + &fixture.watchtower, + &[], + &fixture.contract, + &format!("renew_channel({}, 100)", left_address.to_readable()), + fixture.miner, + ); + + fixture + .chain + .set_height(fixture.chain.height().saturating_add(25_000)); + let manifest: serde_json::Value = serde_json::from_str(CONTRACT_MANIFEST).unwrap(); + let mut recoverable = 0usize; + for key in manifest["registry_storage_keys"].as_array().unwrap() { + let key = Value::bytes(key.as_str().unwrap().as_bytes().to_vec()); + let debug = VMStateRead::wrap(fixture.chain.state()) + .debug_storage_get( + &vm::rt::GasExtra::new(fixture.chain.height()), + &vm::rt::SpaceCap::new(fixture.chain.height()), + fixture.chain.height(), + &fixture.contract.to_addr(), + &key, + ) + .unwrap() + .expect("registry key must remain recoverable"); + recoverable += usize::from(debug.recoverable); + } + for prefix in manifest["channel_storage_prefixes"].as_array().unwrap() { + let key = channel_key(prefix.as_str().unwrap(), &left_address); + let debug = VMStateRead::wrap(fixture.chain.state()) + .debug_storage_get( + &vm::rt::GasExtra::new(fixture.chain.height()), + &vm::rt::SpaceCap::new(fixture.chain.height()), + fixture.chain.height(), + &fixture.contract.to_addr(), + &key, + ) + .unwrap() + .expect("channel key must remain recoverable"); + recoverable += usize::from(debug.recoverable); + } + assert!( + recoverable > 0, + "fixture must reach recoverable lease state" + ); + + confirm_call( + &mut fixture.chain, + &fixture.watchtower, + &[], + &fixture.contract, + "renew_registry(100)", + fixture.miner, + ); + confirm_call( + &mut fixture.chain, + &fixture.watchtower, + &[], + &fixture.contract, + &format!("renew_channel({}, 100)", left_address.to_readable()), + fixture.miner, + ); + for key in manifest["registry_storage_keys"].as_array().unwrap() { + assert_ne!( + fixture.chain.storage( + &fixture.contract, + &Value::bytes(key.as_str().unwrap().as_bytes().to_vec()) + ), + Value::Nil + ); + } + for prefix in manifest["channel_storage_prefixes"].as_array().unwrap() { + assert_ne!( + fixture.chain.storage( + &fixture.contract, + &channel_key(prefix.as_str().unwrap(), &left_address) + ), + Value::Nil + ); + } + assert_eq!( + fixture + .chain + .storage(&fixture.contract, &channel_key("c_status_", &left_address)), + Value::U8(2) + ); +} diff --git a/x16rs-cuda/build.rs b/x16rs-cuda/build.rs index 5a91699b..937959e6 100644 --- a/x16rs-cuda/build.rs +++ b/x16rs-cuda/build.rs @@ -88,7 +88,7 @@ fn main() { let Some(cuda_root) = cuda_root else { println!( - "cargo:warning=CUDA Toolkit not found (set CUDA_PATH or install NVIDIA CUDA) — build without GPU kernels" + "cargo:warning=CUDA Toolkit not found (set CUDA_PATH or install NVIDIA CUDA), build without GPU kernels" ); return; }; @@ -102,9 +102,50 @@ fn main() { println!("cargo:rerun-if-env-changed=CUDA_HOME"); let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let opencl_dir = manifest_dir.join("../x16rs/opencl"); let cuda_dir = manifest_dir.join("cuda"); + // block_miner.cu #includes util.cl, sha3_256.cl and x16rs.cl straight out of + // x16rs/opencl: the two backends share the algorithm sources, and only the + // launch wrapper differs. X16RS_CUDA_KERNEL_DIR redirects that include to + // another tree. + // + // This is what gives CUDA the same fault-injection proof OpenCL has. The + // OpenCL gate is believed because it was shown to FAIL on three deliberate + // defects (`scripts/x16rs_gate_trees.py`), and it could be shown that + // cheaply because OpenCL compiles its kernels at runtime from a directory + // the caller names. nvcc compiles these at BUILD time, so without a knob + // here the only honest thing anyone could say about the CUDA gate is that it + // has never been seen to fail. With it: + // + // python scripts/x16rs_gate_trees.py x16rs/opencl /tmp/trees faults + // X16RS_CUDA_KERNEL_DIR=/tmp/trees/faults/A \ + // cargo build --release --features cuda --bin x16rs_gate + // ./x16rs_gate equiv --backend cuda # must exit 3, naming shabal + // + // NEVER point a miner at a build made this way; every tree under faults/ + // produces wrong hashes on purpose. + let opencl_dir = match env::var("X16RS_CUDA_KERNEL_DIR") { + Ok(dir) if !dir.trim().is_empty() => { + let dir = PathBuf::from(dir); + if !dir.join("x16rs.cl").is_file() { + panic!( + "X16RS_CUDA_KERNEL_DIR={} has no x16rs.cl; refusing to build CUDA kernels \ + against a directory that is not a kernel tree (it would silently fall back \ + to the shipping one and the build would look like a fault injection that \ + changed nothing)", + dir.display() + ); + } + println!( + "cargo:warning=x16rs-cuda: kernels from X16RS_CUDA_KERNEL_DIR={} instead of \ + x16rs/opencl. This is a MEASURING build. Do not mine with it.", + dir.display() + ); + dir + } + _ => manifest_dir.join("../x16rs/opencl"), + }; + // cc-rs defaults pass GCC flags (-ffunction-sections) that nvcc rejects. unsafe { env::set_var("CRATE_CC_NO_DEFAULTS", "1"); @@ -160,4 +201,9 @@ fn main() { println!("cargo:rerun-if-changed=cuda/block_miner.cu"); println!("cargo:rerun-if-changed=cuda/ocl_compat.cuh"); println!("cargo:rerun-if-changed=../x16rs/opencl"); + // Without this, switching kernel trees would reuse the previous build's + // object file and a fault-injection run would silently measure the shipping + // kernel, i.e. report a PASS for a tree that is broken on purpose. + println!("cargo:rerun-if-env-changed=X16RS_CUDA_KERNEL_DIR"); + println!("cargo:rerun-if-changed={}", opencl_dir.display()); } diff --git a/x16rs-cuda/cuda/block_miner.cu b/x16rs-cuda/cuda/block_miner.cu index dd5c58b1..4306ad9f 100644 --- a/x16rs-cuda/cuda/block_miner.cu +++ b/x16rs-cuda/cuda/block_miner.cu @@ -4,12 +4,12 @@ #include "sha3_256.cl" #include "x16rs.cl" -__constant__ sph_u64 x16rs_d_H_blake[8] = { - SPH_C64(0x6A09E667F3BCC908), SPH_C64(0xBB67AE8584CAA73B), - SPH_C64(0x3C6EF372FE94F82B), SPH_C64(0xA54FF53A5F1D36F1), - SPH_C64(0x510E527FADE682D1), SPH_C64(0x9B05688C2B3E6C1F), - SPH_C64(0x1F83D9ABFB41BD6B), SPH_C64(0x5BE0CD19137E2179), -}; +// CUDA needs the blake IV as a file-scope __constant__ (X16RS_DECLARE_H_BLAKE +// under __CUDA__ just points H_blake at it), but the VALUES must not be a second +// copy: see the comment on X16RS_H_BLAKE_INIT in x16rs.cl. Spelling the eight +// words out again here is what made the fault tree that flips a blake IV bit a +// no-op for this backend. +__constant__ sph_u64 x16rs_d_H_blake[8] = X16RS_H_BLAKE_INIT; inline __device__ int diff_big_hash_dev(const hash_32 *src, const hash_32 *tar) { diff --git a/x16rs-cuda/src/lib.rs b/x16rs-cuda/src/lib.rs index 96d5b5f1..abfb0822 100644 --- a/x16rs-cuda/src/lib.rs +++ b/x16rs-cuda/src/lib.rs @@ -80,6 +80,111 @@ pub struct CudaDeviceInfo { pub multiprocessor_count: i32, } +/// Device memory one nonce slot of a batch costs. +/// +/// `alloc_device_buffers` gives every nonce in the launch window a 32-byte hash +/// in `global_hashes` and a 4-byte index in `global_order`; everything else it +/// allocates is per work group or fixed. So a launch of +/// `work_groups * local_size * unit_size` nonces needs that many times this, and +/// an auto-tuner can decide whether a candidate shape fits the card BEFORE +/// asking cudaMalloc for it and having the whole tune die on an allocation +/// failure. +pub const DEVICE_BYTES_PER_NONCE: u64 = (HASH_BYTES + 4) as u64; + +/// What one CUDA device can be asked for, measured rather than assumed. +/// +/// This exists for the auto-tuner. The OpenCL side learns a card's launch limits +/// by opening it through `initialize_opencl` and reading `OpenCLResources`; +/// nothing equivalent existed for CUDA, so a tuner had no honest way to choose +/// the work-group axis and would have had to guess it from a table. Every field +/// here is a runtime query, and the two derived helpers below turn them into the +/// two numbers a candidate grid actually needs. +#[derive(Debug, Clone)] +pub struct CudaDeviceLimits { + pub device: CudaDeviceInfo, + /// Free and total device memory at the moment of the query. Free is the one + /// that bounds a launch: on a rig where the miner is already running, or on + /// a shared Colab GPU, total is not available to this process. + pub free_global_mem: u64, + pub total_global_mem: u64, + pub max_threads_per_block: i32, + pub max_threads_per_multiprocessor: i32, + pub warp_size: i32, + pub registers_per_multiprocessor: i32, + pub shared_mem_per_multiprocessor: i32, + /// `x16rs_cuda_main`'s own attributes, from cudaFuncGetAttributes. The batch + /// kernel is register-bound (255 registers a thread measured on sm_75), and + /// `kernel_max_threads_per_block` is what decides whether the fixed + /// 256-thread block this kernel requires is launchable at all. + pub kernel_max_threads_per_block: i32, + pub kernel_num_regs: i32, + pub kernel_static_shared_bytes: u64, + pub kernel_local_bytes_per_thread: u64, + /// Blocks of [`DEFAULT_LOCAL_SIZE`] threads that can be resident on one SM + /// at once, straight from cudaOccupancyMaxActiveBlocksPerMultiprocessor + /// rather than from occupancy arithmetic done here. 0 when the runtime + /// declined to answer, which the callers treat as "assume one". + pub blocks_per_multiprocessor: i32, +} + +impl CudaDeviceLimits { + /// The smallest work-group count that puts a resident block on every SM. + /// + /// Below this the card is idle by construction: a launch of fewer blocks + /// than the device has multiprocessors leaves some of them with no work at + /// all, so its hashrate says nothing about the shape and everything about + /// the launch being too small. It is the floor of the tuner's work-group + /// axis for that reason, not a preference. + pub fn work_groups_that_fill_the_card(&self) -> u32 { + let mps = self.device.multiprocessor_count.max(1) as u32; + let per_mp = self.blocks_per_multiprocessor.max(1) as u32; + mps.saturating_mul(per_mp).max(1) + } + + /// The largest work-group count whose buffers fit in `share` of free device + /// memory at `unit_size`. + /// + /// `share` is well under 1 on purpose: cudaMalloc needs contiguous space, the + /// CUDA context itself holds a few hundred megabytes that `cudaMemGetInfo` + /// has already excluded but a display driver has not, and a tuner that sized + /// its grid to the last free byte would spend the sweep watching allocations + /// fail. + pub fn max_work_groups_for(&self, unit_size: u32, share: f64) -> u32 { + let unit_size = unit_size.max(1) as u64; + let per_group = (DEFAULT_LOCAL_SIZE as u64) + .saturating_mul(unit_size) + .saturating_mul(DEVICE_BYTES_PER_NONCE); + if per_group == 0 { + return 1; + } + let budget = (self.free_global_mem as f64 * share.clamp(0.05, 0.95)) as u64; + (budget / per_group).clamp(1, u32::MAX as u64) as u32 + } + + /// One line an operator can read, and a reviewer can check against + /// `nvidia-smi -q`. + pub fn describe(&self) -> String { + format!( + "device #{} {} (SM {}.{}, {} MPs, {:.1}/{:.1} GiB free, {} block(s) of {} threads \ + resident per MP; kernel numRegs={} staticShared={}B localPerThread={}B \ + maxThreadsPerBlock={})", + self.device.index, + self.device.name, + self.device.compute_major, + self.device.compute_minor, + self.device.multiprocessor_count, + self.free_global_mem as f64 / (1024.0 * 1024.0 * 1024.0), + self.total_global_mem as f64 / (1024.0 * 1024.0 * 1024.0), + self.blocks_per_multiprocessor, + DEFAULT_LOCAL_SIZE, + self.kernel_num_regs, + self.kernel_static_shared_bytes, + self.kernel_local_bytes_per_thread, + self.kernel_max_threads_per_block, + ) + } +} + /// The device allocations one miner instance owns. Kept together behind a mutex /// inside `CudaMiner` so a sticky-fault recovery can destroy the CUDA context and /// swap in a freshly allocated set without the caller having to rebuild the miner. @@ -231,6 +336,18 @@ impl CudaMiner { cuda_list_devices() } + /// Everything one device can be asked for, without allocating a miner. + /// + /// Binds the CUDA context for `device_index` (cudaMemGetInfo and the kernel + /// attribute queries both need one), so it is not free; it is meant to be + /// called once by a caller that is about to use the device anyway. + pub fn limits(device_index: i32) -> CudaResult { + if !cuda_available() { + return Err(CudaError::NotCompiled); + } + cuda_device_limits(device_index) + } + pub fn new(device_index: i32, workgroups: u32, unit_size: u32) -> CudaResult { if !cuda_available() { return Err(CudaError::NotCompiled); @@ -369,11 +486,27 @@ mod driver { fn cudaGetErrorString(err: CudaError_t) -> *const i8; fn cudaFuncGetAttributes(attr: *mut CudaFuncAttributes, func: *const c_void) -> CudaError_t; + // Free and total device memory for the CURRENT device, so cudaSetDevice + // has to come first. `free` is what bounds a launch: it already excludes + // the primary context's own few hundred megabytes, and on a shared card + // it excludes whatever the other process holds. + fn cudaMemGetInfo(free: *mut usize, total: *mut usize) -> CudaError_t; + // The runtime's own occupancy calculator. Used rather than re-deriving + // blocks-per-SM from registers and shared memory here: the arithmetic + // has per-architecture allocation granularities in it, and a tuner that + // got them subtly wrong would size its work-group axis from a number + // that looks authoritative and is not. + fn cudaOccupancyMaxActiveBlocksPerMultiprocessor( + num_blocks: *mut i32, + func: *const c_void, + block_size: i32, + dynamic_smem_size: usize, + ) -> CudaError_t; } // Mirrors CUDA's `cudaFuncAttributes` (leading fields only; trailing reserved for // forward-compat with newer toolkits). Used to clamp the launch block size to the - // kernel's own `maxThreadsPerBlock` — a register-heavy kernel can have a per-kernel + // kernel's own `maxThreadsPerBlock`, a register-heavy kernel can have a per-kernel // limit below the device's 1024, and launching above it returns // cudaErrorInvalidConfiguration (9). #[repr(C)] @@ -449,9 +582,14 @@ mod driver { const CUDA_ERROR_DEVICE_UNINITIALIZED: i32 = 201; // Stable cudaDeviceAttr enum values (CUDA runtime API). + const CUDA_DEV_ATTR_MAX_THREADS_PER_BLOCK: i32 = 1; + const CUDA_DEV_ATTR_WARP_SIZE: i32 = 10; const CUDA_DEV_ATTR_MULTIPROCESSOR_COUNT: i32 = 16; + const CUDA_DEV_ATTR_MAX_THREADS_PER_MULTIPROCESSOR: i32 = 39; const CUDA_DEV_ATTR_COMPUTE_CAPABILITY_MAJOR: i32 = 75; const CUDA_DEV_ATTR_COMPUTE_CAPABILITY_MINOR: i32 = 76; + const CUDA_DEV_ATTR_MAX_SHARED_MEMORY_PER_MULTIPROCESSOR: i32 = 81; + const CUDA_DEV_ATTR_MAX_REGISTERS_PER_MULTIPROCESSOR: i32 = 82; // Oversized tail so cudaGetDeviceProperties (which writes the FULL struct) // never overflows across CUDA versions. Only `name` (offset 0) is read from @@ -498,19 +636,64 @@ mod driver { ); } + /// Text for a `cudaError_t`, without trusting the runtime to supply any. + /// + /// `cudaGetErrorString` is documented to return a string for every code, and + /// it does not. Measured on CUDA 13.3 with no NVIDIA driver present, the + /// very first call a miner makes - `cudaGetDeviceCount` - returns 35 + /// (cudaErrorInsufficientDriver) and `cudaGetErrorString(35)` returns NULL, + /// because 35 is one of the codes CUDA 13 dropped from its table. The + /// previous `CStr::from_ptr(cudaGetErrorString(err))` then dereferenced NULL + /// and the process died with an access violation. + /// + /// That is not a corner case, it is the most common CUDA failure there is: + /// no driver, or a driver older than the runtime the binary was built + /// against. A Colab session whose GPU runtime is not attached hits it on the + /// first call. `panic = 'unwind'` cannot catch it either - an access + /// violation is not a Rust panic - so the miner did not fall back to CPU, it + /// died, and the operator got no message at all. + /// + /// So: a null pointer becomes text, and the codes that mean "there is no + /// usable GPU here" get an explanation an operator can act on rather than a + /// bare number. + fn error_text(err: CudaError_t) -> String { + let raw = unsafe { cudaGetErrorString(err) }; + let from_runtime = if raw.is_null() { + None + } else { + Some( + unsafe { CStr::from_ptr(raw) } + .to_string_lossy() + .into_owned(), + ) + }; + let hint = match err { + 35 => Some( + "the installed NVIDIA driver is older than the CUDA runtime this binary was \ + built against, or there is no NVIDIA driver at all", + ), + 100 => Some("no CUDA-capable device is present"), + 101 => Some("the CUDA device index asked for does not exist"), + _ => None, + }; + match (from_runtime, hint) { + (Some(text), Some(hint)) if !text.is_empty() => format!("{text} ({hint})"), + (Some(text), None) if !text.is_empty() => text, + (_, Some(hint)) => hint.to_string(), + (_, None) => format!("the CUDA runtime supplied no description for error {err}"), + } + } + fn check(err: CudaError_t) -> CudaResult<()> { if err == CUDA_SUCCESS { Ok(()) } else { - unsafe { - let cstr = CStr::from_ptr(cudaGetErrorString(err)); - // Carry the raw code, not just the text, so sticky context faults can - // be told apart from per-launch failures (see CudaError::is_sticky). - Err(CudaError::Driver { - code: err, - message: cstr.to_string_lossy().into_owned(), - }) - } + // Carry the raw code, not just the text, so sticky context faults can + // be told apart from per-launch failures (see CudaError::is_sticky). + Err(CudaError::Driver { + code: err, + message: error_text(err), + }) } } @@ -550,6 +733,96 @@ mod driver { Ok(out) } + /// One device attribute, or `default` when this runtime does not know it. + /// + /// Not every enumerant exists on every toolkit, and a limits probe must not + /// fail as a whole because one of its dozen queries is newer than the + /// installed CUDA. The caller decides what a zero means. + fn attr_or(index: i32, attr: i32, default: i32) -> i32 { + let mut value = 0i32; + if unsafe { cudaDeviceGetAttribute(&mut value, attr, index) } == CUDA_SUCCESS { + value + } else { + default + } + } + + pub fn cuda_device_limits(index: i32) -> CudaResult { + let device = cuda_list_devices()? + .into_iter() + .find(|d| d.index == index) + .ok_or_else(|| { + CudaError::InvalidArgs(format!("CUDA device #{index} is not present")) + })?; + + // cudaMemGetInfo and cudaFuncGetAttributes both read the CURRENT + // device's context, so bind it first or the answers describe device 0. + check(unsafe { cudaSetDevice(index) })?; + + let mut free = 0usize; + let mut total = 0usize; + check(unsafe { cudaMemGetInfo(&mut free, &mut total) })?; + + let mut attrs = CudaFuncAttributes::zeroed(); + let kernel_ok = + unsafe { cudaFuncGetAttributes(&mut attrs, x16rs_cuda_main as *const c_void) } + == CUDA_SUCCESS; + + // Blocks resident per SM at the ONE block size this kernel is correct + // at. Asking about any other size would describe a launch that cannot + // happen: the batch kernel's shared local_nonces[256] and its + // power-of-two tree reduction fix the block at DEFAULT_LOCAL_SIZE. + let mut blocks = 0i32; + let occupancy_ok = unsafe { + cudaOccupancyMaxActiveBlocksPerMultiprocessor( + &mut blocks, + x16rs_cuda_main as *const c_void, + DEFAULT_LOCAL_SIZE as i32, + 0, + ) + } == CUDA_SUCCESS; + + Ok(CudaDeviceLimits { + device, + free_global_mem: free as u64, + total_global_mem: total as u64, + max_threads_per_block: attr_or(index, CUDA_DEV_ATTR_MAX_THREADS_PER_BLOCK, 0), + max_threads_per_multiprocessor: attr_or( + index, + CUDA_DEV_ATTR_MAX_THREADS_PER_MULTIPROCESSOR, + 0, + ), + warp_size: attr_or(index, CUDA_DEV_ATTR_WARP_SIZE, 0), + registers_per_multiprocessor: attr_or( + index, + CUDA_DEV_ATTR_MAX_REGISTERS_PER_MULTIPROCESSOR, + 0, + ), + shared_mem_per_multiprocessor: attr_or( + index, + CUDA_DEV_ATTR_MAX_SHARED_MEMORY_PER_MULTIPROCESSOR, + 0, + ), + kernel_max_threads_per_block: if kernel_ok { + attrs.max_threads_per_block + } else { + 0 + }, + kernel_num_regs: if kernel_ok { attrs.num_regs } else { 0 }, + kernel_static_shared_bytes: if kernel_ok { + attrs.shared_size_bytes as u64 + } else { + 0 + }, + kernel_local_bytes_per_thread: if kernel_ok { + attrs.local_size_bytes as u64 + } else { + 0 + }, + blocks_per_multiprocessor: if occupancy_ok { blocks } else { 0 }, + }) + } + /// Never rebuild the context more than this many times in a row without a clean /// batch in between; past that the card is broken, not hiccuping. const MAX_STICKY_CONTEXT_RESETS: u32 = 5; @@ -717,7 +990,7 @@ mod driver { y: u32, z: u32, } - // RUNTIME API cudaLaunchKernel — real signature: + // RUNTIME API cudaLaunchKernel, real signature: // cudaError_t cudaLaunchKernel(const void*, dim3, dim3, void**, size_t, cudaStream_t) // dim3 is passed BY VALUE and `args` comes BEFORE sharedMem/stream. The previous // declaration used the DRIVER API cuLaunchKernel layout (grid/block as six u32s, @@ -985,7 +1258,7 @@ mod driver { // Each workgroup's kernel reduction returns the lexicographically SMALLEST hash // it found (diff_big_hash keeps the smaller of each pair), because mining wants // the hash closest to zero (hash < target). So aggregate across workgroups by - // keeping the MINIMUM too — replace the running best when the candidate is + // keeping the MINIMUM too, replace the running best when the candidate is // smaller, i.e. when best > candidate. let mut best_nonce = 0u32; let mut best_hash = [0u8; HASH_BYTES]; @@ -1149,6 +1422,11 @@ fn cuda_list_devices() -> CudaResult> { Err(CudaError::NotCompiled) } +#[cfg(not(cuda_available))] +fn cuda_device_limits(_: i32) -> CudaResult { + Err(CudaError::NotCompiled) +} + #[cfg(not(cuda_available))] fn cuda_init_miner(_: i32, _: u32, _: u32) -> CudaResult { Err(CudaError::NotCompiled) @@ -1487,6 +1765,40 @@ mod tests { } } + /// The blake IV must not be written out in block_miner.cu. + /// + /// It was, and the cost was precise: `scripts/x16rs_gate_trees.py` builds + /// the fault trees that prove the equivalence gate can FAIL by rewriting + /// `x16rs/opencl/x16rs.cl`, and one of the three flips a bit of blake's IV. + /// With a second copy here, that tree compiled to PTX byte-identical to the + /// shipping kernel (measured: `diff` of the two .ptx files was empty), so + /// the CUDA gate would have returned PASS for a kernel that was broken on + /// purpose and the proof would have been hollow. + /// + /// A grep is a crude test. It is also the only one that fails at the moment + /// someone pastes the constant back in, which is when it is cheap to fix + /// rather than after a gate run on Colab has been believed. + #[test] + fn the_blake_iv_is_not_duplicated_into_the_cuda_source() { + let cu = include_str!("../cuda/block_miner.cu"); + assert!( + cu.contains("X16RS_H_BLAKE_INIT"), + "block_miner.cu must take the blake IV from x16rs.cl's shared macro" + ); + for word in [ + "0x6A09E667F3BCC908", + "0xBB67AE8584CAA73B", + "0x5BE0CD19137E2179", + ] { + assert!( + !cu.contains(word), + "block_miner.cu spells out the blake IV word {word}. A fault injected into \ + x16rs.cl would then leave this backend untouched, and the CUDA gate would pass \ + a kernel that is wrong on purpose." + ); + } + } + #[test] fn the_launch_argument_array_matches_the_kernel_signature() { // cudaLaunchKernel takes an untyped void**, so nothing in the toolchain @@ -1508,4 +1820,96 @@ mod tests { SHARE_LIST_CAPACITY ); } + + /// A Tesla T4 as `cudaDeviceGetAttribute` and `cudaMemGetInfo` describe it, + /// with the batch kernel's own attributes as they were measured on that card + /// (numRegs 255, 33984 B of static shared, 792 B of local per thread, + /// maxThreadsPerBlock 256, arch 7.5). + fn tesla_t4() -> CudaDeviceLimits { + CudaDeviceLimits { + device: CudaDeviceInfo { + index: 0, + name: "Tesla T4".to_string(), + compute_major: 7, + compute_minor: 5, + multiprocessor_count: 40, + }, + // 15109 MiB of a 15360 MiB card, which is what a fresh Colab session + // reports before a context is bound. + free_global_mem: 15_109 * 1024 * 1024, + total_global_mem: 15_360 * 1024 * 1024, + max_threads_per_block: 1024, + max_threads_per_multiprocessor: 1024, + warp_size: 32, + registers_per_multiprocessor: 65_536, + shared_mem_per_multiprocessor: 65_536, + kernel_max_threads_per_block: 256, + kernel_num_regs: 255, + kernel_static_shared_bytes: 33_984, + kernel_local_bytes_per_thread: 792, + // 255 registers x 256 threads is 65280 of the SM's 65536, so exactly + // one block of this kernel is resident per multiprocessor. + blocks_per_multiprocessor: 1, + } + } + + #[test] + fn the_work_group_floor_is_one_resident_block_on_every_multiprocessor() { + let t4 = tesla_t4(); + // 40 SMs, one block each. A launch smaller than this leaves whole + // multiprocessors with nothing to do, so its hashrate measures the + // launch being too small rather than the shape being wrong. + assert_eq!(t4.work_groups_that_fill_the_card(), 40); + + // A card that fits two blocks per SM needs twice as many work groups to + // be full, and the floor follows the occupancy rather than the SM count. + let roomy = CudaDeviceLimits { + blocks_per_multiprocessor: 2, + ..tesla_t4() + }; + assert_eq!(roomy.work_groups_that_fill_the_card(), 80); + + // A runtime that declined to answer the occupancy query must not produce + // a floor of zero, which would make every launch "full". + let unknown = CudaDeviceLimits { + blocks_per_multiprocessor: 0, + ..tesla_t4() + }; + assert_eq!(unknown.work_groups_that_fill_the_card(), 40); + } + + #[test] + fn the_work_group_ceiling_is_the_memory_a_batch_really_needs() { + let t4 = tesla_t4(); + // A launch costs local_size * unit_size * 36 bytes per work group. At + // unit_size 128 that is 256 * 128 * 36 = 1179648 bytes a group, so half + // of 15109 MiB is a few thousand groups. Checked against the arithmetic + // rather than against a remembered number. + let share = 0.5; + let per_group = (DEFAULT_LOCAL_SIZE as u64) * 128 * DEVICE_BYTES_PER_NONCE; + let expected = ((t4.free_global_mem as f64 * share) as u64) / per_group; + assert_eq!(t4.max_work_groups_for(128, share) as u64, expected); + + // The ceiling has to fall as unit_size rises, or the tuner would size + // its grid from the cheapest shape and then fail to allocate the + // expensive one. + assert!(t4.max_work_groups_for(64, share) > t4.max_work_groups_for(128, share)); + + // A card with almost nothing free still yields a launchable shape rather + // than zero work groups, which is not a shape at all. + let tight = CudaDeviceLimits { + free_global_mem: 1024, + ..tesla_t4() + }; + assert_eq!(tight.max_work_groups_for(128, 0.5), 1); + } + + #[test] + fn a_nonce_costs_the_bytes_the_allocator_asks_for() { + // `alloc_device_buffers` gives every nonce a 32-byte hash in + // global_hashes and a 4-byte index in global_order. If that ever changes + // and this does not, a tuner sizing its work-group ceiling from free + // memory would ask cudaMalloc for more than it believed. + assert_eq!(DEVICE_BYTES_PER_NONCE, (HASH_BYTES + 4) as u64); + } } diff --git a/x16rs/opencl/x16rs.cl b/x16rs/opencl/x16rs.cl index da7eb259..e6d3c962 100644 --- a/x16rs/opencl/x16rs.cl +++ b/x16rs/opencl/x16rs.cl @@ -4,7 +4,7 @@ #ifndef __CUDA__ #define OCL_AS_ULONG_UINT2_S10(v) as_ulong(as_uint2(v).s10) #ifdef AMD_GFX_GFX1201 -/* RDNA4: use __constant lookup tables — avoids ~34KB __local per work-group. */ +/* RDNA4: use __constant lookup tables, avoids ~34KB __local per work-group. */ #define __local_array /**/ #define OCL_LOCAL_PTR __constant #else @@ -29,7 +29,7 @@ typedef int sph_s32; // CUDA build: match the algorithm kernels (jh.cl etc.) which do // `typedef ulong sph_u64`. ulong is supplied by ocl_compat.cuh. Using the same // spelling keeps sph_u64 a single consistent type across the whole CUDA - // translation unit — nvcc rejects a conflicting `unsigned long long` here vs + // translation unit, nvcc rejects a conflicting `unsigned long long` here vs // `ulong` in jh.cl as an "invalid redeclaration". OpenCL is unaffected (#else). typedef ulong sph_u64; #else @@ -138,17 +138,32 @@ typedef union ALIGN { unsigned char h1[16]; } diamond_t; -#ifdef __CUDA__ -#define X16RS_DECLARE_H_BLAKE() \ - const sph_u64 * const H_blake = x16rs_d_H_blake -#else -#define X16RS_DECLARE_H_BLAKE() \ - __constant sph_u64 ALIGN H_blake[8] = { \ +/* The blake IV, in ONE place. + * + * It used to be written out twice: here for OpenCL, and again in + * x16rs-cuda/cuda/block_miner.cu for CUDA, which needs a __constant__ at file + * scope rather than a declaration inside the kernel. Two copies of a consensus + * constant is a standing invitation for the backends to disagree, and it had one + * concrete cost already: scripts/x16rs_gate_trees.py builds its fault trees by + * rewriting THIS file, so the "one bit flipped in blake's IV" tree that proves + * the gate can fail changed the OpenCL kernel and left the CUDA one untouched. + * The CUDA gate would have passed a kernel that was broken on purpose, and the + * proof would have been worthless without anyone noticing. + * + * Both backends now read these eight words, and a fault tree reaches both. */ +#define X16RS_H_BLAKE_INIT { \ SPH_C64(0x6A09E667F3BCC908), SPH_C64(0xBB67AE8584CAA73B), \ SPH_C64(0x3C6EF372FE94F82B), SPH_C64(0xA54FF53A5F1D36F1), \ SPH_C64(0x510E527FADE682D1), SPH_C64(0x9B05688C2B3E6C1F), \ SPH_C64(0x1F83D9ABFB41BD6B), SPH_C64(0x5BE0CD19137E2179) \ } + +#ifdef __CUDA__ +#define X16RS_DECLARE_H_BLAKE() \ + const sph_u64 * const H_blake = x16rs_d_H_blake +#else +#define X16RS_DECLARE_H_BLAKE() \ + __constant sph_u64 ALIGN H_blake[8] = X16RS_H_BLAKE_INIT #endif #ifdef AMD_GFX_GFX1201 @@ -266,8 +281,67 @@ typedef union ALIGN { hash_x16rs_func_15(&(local_hashes)[hash_pos[0]]); \ break; \ } \ - barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \ } \ + /* ONE barrier per round, at the end of the round, and it is load bearing. \ + * \ + * Why one per round is enough. `local_order` is a PERMUTATION of the \ + * group's slot indices: the scatter pass above writes each slot index \ + * (index + h) to exactly one position, and the positions it can reach are \ + * exactly [starting_index[mod], starting_index[mod] + histogram[mod]), \ + * whose union over the 16 buckets is [0, local_size * unit_size). The hash \ + * pass then reads position (local_size * h) + local_id, which over all \ + * (h, local_id) covers that same range exactly once. So inside the hash \ + * pass every slot is read and written by exactly ONE work item in exactly \ + * ONE iteration of h: no two iterations of h can collide, and a barrier \ + * between them prevents nothing. The only cross work item dependency in \ + * the whole macro is round r's hash pass writing slots that round r+1's \ + * histogram pass reads, and one barrier here covers all of it. Removing \ + * removing the per hash barrier is worth this much, and how much \ + * depends strongly on unit_size, because the barriers are a fixed cost \ + * per hash while the work between them is not. Paired A/B on a \ + * gfx1201, both trees alternating inside one process with the order \ + * swapped, every run also reporting byte identical output: \ + * \ + * 64x256x12 +45.84% (barriers dominate) \ + * 64x256x48 +8.20% \ + * 48x256x48 +8.14% \ + * 64x256x64 +6.54% <- the shipped poworker.config.ini \ + * 64x256x96 +4.10% \ + * 64x256x192 +1.41% \ + * \ + * An earlier note here read "+8.96% at the shipped shape (unit_size \ + * 12)". Both halves were wrong together: 8.96 came from a run at \ + * unit_size 48, and unit_size 12 measures 45.84 on this card. The \ + * honest headline for an operator on the shipped shape is +6.5%. \ + * \ + * Why the fence must name GLOBAL. `local_hashes` is not local. Both OpenCL \ + * call sites and the CUDA one alias it onto GLOBAL memory: \ + * x16rs_main.cl, x16rs_diamond.cl and x16rs-cuda/cuda/block_miner.cu all \ + * do `local_hashes = global_hashes + (group_id * local_size * unit_size)`. \ + * The hash functions above therefore write global memory, and a \ + * CLK_LOCAL_MEM_FENCE alone would order none of it. CLK_LOCAL_MEM_FENCE is \ + * kept alongside so the macro stays correct if a caller ever does pass a \ + * genuinely __local buffer, and because it is what lets the histogram reset \ + * barrier at the top of the round stay LOCAL only: the two are a pair. \ + * \ + * What breaks if someone deletes it. Two separate races, not one. \ + * (1) Round r+1's histogram pass reads (local_hashes)[index + h] for its \ + * own slots, but those slots were written in round r by OTHER work items, \ + * because the permutation does not map a slot back to its owner. Without \ + * this barrier the histogram, and therefore the algorithm order, is built \ + * from a mix of round r and round r-1 values, and the hash silently \ + * diverges from consensus on any device that does not happen to run the \ + * group in lockstep. \ + * (2) The LAST round's writes would be unfenced against everything after \ + * the macro. None of the three call sites has a barrier between the macro \ + * and its first read of another work item's slots: x16rs_main.cl reads \ + * local_hashes[index + i] in the share loop and the best_hash loop, \ + * x16rs_diamond.cl in diamond_hash(local_hashes[index]...), block_miner.cu \ + * in the same two places, all BEFORE their next __syncthreads(). This is \ + * also why upgrading the histogram reset barrier to LOCAL|GLOBAL is not an \ + * alternative to keeping a barrier here: that would fix (1) and leave (2) \ + * open, because there is no round r+1 after the last round. */ \ + barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE); \ } // blake