Files
amethyst/quartz/plans/2026-08-13-gpu-pow-mining.md
Claude 7a271a96fe docs: the 3.7x SHA-256 gap was a bad measurement, not a real gap
Chased the unexplained gap between quartz's MessageDigest path (172
ns/block) and OpenSSL's SHA-NI path (47 ns/block) on the same CPU. There
is no gap: re-measured with best-of-5, sha256Into runs at 46-48 ns/block
at every payload size, matching OpenSSL's 47 exactly.

Ruled out in turn: the HotSpot intrinsic is active (disabling it costs 8x,
C1-only costs 12x), the SUN provider is the one serving SHA-256,
compilation shape is irrelevant (OSR loop vs hot C2 method differ by
1.02x), the Gradle test worker reproduces the fast number, both the
original timing method and a best-of-5 agree, and four CPU hogs on four
cores change nothing.

What is left is the original sample itself: one unguarded 500 ms window,
no best-of-N, taken in the same Gradle invocation as the module's first
full compile with a 6 GB Gradle daemon and an 8 GB Kotlin daemon settling
on a 4-core container.

Corrected model is 42 ns fixed + 46 ns per block, which changes the
midstate estimate rather than the GPU verdict: ~3x on JVM targets (the
fixed cost is small), while Android now turns on one number nobody has
measured -- Conscrypt's per-digest JNI cost, which OpenJDK does not pay at
all since its intrinsic runs over pure Java. Flags that as the measurement
to take on-device before any midstate or native-batching work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 14:27:30 +00:00

300 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Can the phone's GPU mine NIP-13 proof of work?
**Date:** 2026-08-13
**Status:** analysis + measurements; recommendation is **NOT to build a GPU miner**
**Verdict:** a flagship phone GPU lands at roughly the same SHA-256 throughput as the
CPU cores the miner already uses, because ARMv8 CPUs have SHA-256 **in silicon** and
mobile GPUs do not. The unclaimed speedup is on the CPU side: a midstate is ~3x on
JVM targets, and on Android it hinges on Conscrypt's per-digest JNI cost, which is
still unmeasured.
Also specifies advancing `created_at` while mining, which shares the same pass
structure a midstate would need.
## The question
Can we offload `PoWMiner`'s hash loop to the phone GPU to mine NIP-13 proof of work
faster?
## What the miner does today
`quartz/…/nip13Pow/miner/PoWMiner.kt` serializes the event once into the NIP-01 id
payload, finds the nonce inside that byte array, then mutates the nonce bytes in
place and re-hashes:
```kotlin
fun reachedDesiredPoW(byteArray: ByteArray) =
PoWRankEvaluator.atLeastPowRank(sha256Into(hashOut, byteArray, byteArray.size), …)
```
`PoWMiner.mine()` races `workers` copies of that search over disjoint nonce prefixes.
`PoWPolicy.minerWorkers()` sets `workers = cores / 2` so the UI and relay client keep
their cores. `sha256Into` is `MessageDigest("SHA-256")` from a `ThreadLocal`
(`quartz/…/utils/sha256/Sha256.jvmAndroid.kt`), i.e. Conscrypt → BoringSSL.
Two facts about that loop drive everything below.
### 1. It re-hashes the whole payload every attempt
`fastMakeJsonForId` serializes `[0,pubkey,created_at,kind,tags,content]`. The PoW tag
is appended as the **last** tag, so only the tail — the rest of the nonce tag plus
the whole content string — changes between attempts. Everything before the 64-byte
SHA-256 block containing the nonce is constant and could be absorbed **once** into a
midstate.
Measured layout (4 tags: 2 `e` + 2 `p`, nonce at byte 407):
| Payload | Size | Padded blocks | Blocks that actually change |
| ------- | ---- | ------------- | --------------------------- |
| 49-char note | 473 B | 8 | **2** |
| 223-char note | 663 B | 11 | **5** |
Six of the eight (resp. eleven) block compressions per attempt are recomputing bytes
that never change. Real posts carry more `p` tags than this sample, which pushes the
nonce further right and makes the constant prefix bigger, not smaller.
### 2. Cost model of the current hash call
Single-thread `sha256Into` on the build container's x86-64 JVM, payload swept from 32 B
to 4 KB:
| Payload | Blocks | hashes/s | µs/hash |
| ------- | ------ | -------- | ------- |
| 32 B | 1 | 4,025,423 | 0.248 |
| 64 B | 2 | 2,197,941 | 0.455 |
| 256 B | 5 | 1,010,665 | 0.989 |
| 1024 B | 17 | 329,215 | 3.038 |
| 4096 B | 65 | 88,845 | 11.256 |
**Those numbers were wrong — about 3.3x pessimistic — and are kept here only because
the first two revisions of this plan reasoned from them.** See the correction below;
everything downstream uses the corrected model.
### 3. Correction: the JVM already runs SHA-256 at hardware speed
The table above suggested `cost ≈ 76 ns + 172 ns/block`, which made the JVM look 3.7x
slower than `openssl speed -evp sha256` (1,357 MB/s ⇒ 47 ns/block) on the same CPU.
That gap does not exist. Re-measured with a best-of-5 loop:
| bytes | blocks | ns/hash | marginal ns/block |
| ----- | ------ | ------- | ----------------- |
| 64 | 2 | 134 | — |
| 256 | 5 | 270 | 45 |
| 512 | 9 | 455 | 46 |
| 1,024 | 17 | 846 | 48 |
| 4,096 | 65 | 3,143 | 47 |
| 65,536 | 1,025 | 47,395 | 46 |
**Corrected model: `cost ≈ 42 ns fixed + 46 ns per 64-byte block`** — the marginal
cost is flat at every size and equals OpenSSL's 47 ns/block. The HotSpot intrinsic is
doing the work: `-XX:-UseSHA256Intrinsics` gives 390 ns/block (8x worse) and
`-XX:TieredStopAtLevel=1` gives 583 ns/block.
The original figure was a single unguarded 500 ms sample with no best-of-N, taken in
the same Gradle invocation as the module's first full compile (4-core container, 6 GB
Gradle daemon + 8 GB Kotlin daemon). Every controlled repetition since lands at
46–48 ns/block: standalone `java` and inside a Gradle test worker; the original
timing method and a best-of-5; with and without four CPU hogs; and immediately after
a forced recompile. Compilation shape was ruled out too — an OSR-compiled loop in
`main` and a hot C2-compiled method differ by 1.02x.
**There is therefore no "slow regime".** OpenJDK reaches hardware SHA-256 with an
intrinsic over pure-Java code — no JNI at all — and ARMv8 crypto extensions land in
the same place (~2 GB/s per core ≈ 32 ns/block). Android is the one platform whose
per-call cost is unmeasured, because Conscrypt makes a real JNI call per digest where
OpenJDK makes none.
**Lesson for the next measurement:** take best-of-N, never a single wall-clock window,
and never in the same Gradle invocation that compiled the code.
## What GPU APIs are actually reachable
| API | Reachable from Kotlin? | Notes |
| --- | --- | ----- |
| **OpenGL ES 3.1 compute** | **Yes** — `android.opengl.GLES31`, no NDK | Only realistic pure-Kotlin path. ES 3.1 guarantees just 4 SSBO bindings; needs an offscreen EGL pbuffer context on its own thread |
| Vulkan compute | No — NDK only | Better (subgroups, less driver overhead), but `amethyst` has no C/C++ toolchain today; would add CMake + NDK to the Android build |
| OpenCL | No | Not part of Android's guaranteed surface; `dlopen("libOpenCL.so")` works on some Qualcomm/ARM devices and not others |
| RenderScript | No | Deprecated in API 31; Google's own migration guide points at ES 3.1 compute |
So: OpenGL ES 3.1 compute shaders, with a mandatory CPU fallback for devices below
ES 3.1 (`minSdk` here is 26, so that fallback is not optional).
## Why the GPU does not win
**Mobile GPUs have no SHA-256 hardware. ARM CPUs do.** ARMv8 Cryptographic Extensions
add `SHA256H`/`SHA256H2`/`SHA256SU0`/`SHA256SU1`, and BoringSSL dispatches to them at
runtime. A GPU has to emulate the same work out of general integer ALU ops.
Ops per 64-byte block in GLSL ES (no rotate builtin, so every `rotr` is shl+shr+or):
- message schedule, 48 × (2 × σ ≈ 9 ops + 3 adds) ≈ 1,000 ops
- 64 rounds × (Σ1 11 + Ch 4 + Σ0 11 + Maj 5 + 7 adds) ≈ 2,400 ops
≈ **3,400 integer ops per block compression.**
| | Throughput per block-compression |
| --- | --- |
| Flagship GPU (Adreno 750 class, ~1.5 T int ops/s at ~40% ALU efficiency) | ≈ **175 M/s** |
| Flagship CPU, ARMv8 SHA-2 ext (~2 GB/s per big core = 31 M blocks/s), 4 workers | ≈ **125 M/s** |
| Mid-range GPU | ≈ 20–50 M/s |
The GPU estimate agrees with the ~80–150 MH/s figure quoted for SHA-256 on mobile in
hashcat-style benchmarks, so two independent derivations land in the same place.
That is **~1.4x on a flagship, and a loss on mid-range hardware** — for a
GPU-vs-CPU offload that would normally be worth 10–50x. Both paths benefit equally
from the midstate optimization, so the ratio does not improve.
### And the 1.4x costs more than it's worth
- **The GPU is the compositor.** Saturating it janks the whole system, including
other apps. The CPU miner already gives up half the cores (`minerWorkers`)
specifically to avoid this; there is no equivalent "half a GPU".
- **Power and thermals.** Sustained GPU load is ~3–5 W and throttles within ~60 s.
`PowMiningForegroundService` runs under the Android 14 `shortService` type with a
hard ~3 minute budget — most of which would be spent throttled.
- **Dispatch latency.** Kernel launch + readback is ~1–5 ms, so batches must be large
(~10⁶ nonces) to amortize. At the difficulties users actually pick, the whole job
can finish inside one batch, wasting most of it.
- **Play policy adjacency.** Google prohibits apps that mine cryptocurrency on-device
(remote *management* is allowed). NIP-13 PoW is not cryptocurrency, but a
GPU-saturating component named "miner" invites review friction that the CPU path
has never attracted.
- **Cost.** ~1,000 lines of GLSL + EGL context management + batch/nonce plumbing +
result verification, Android-only, shared with neither `desktopApp` nor `cli`, and
it has to be kept in lockstep with the CPU nonce enumeration forever.
## What to do instead
**1. Midstate — ~3x on JVM targets, unknown on Android.**
SHA-256 is `state ← compress(state, block)` over 64-byte blocks, strictly sequential.
Every block before the one containing the nonce is identical on every attempt, so it
can be absorbed **once** into 8 uint32 words and restored per attempt. The boundary is
`(searchFrom / 64) * 64` — `searchFrom`, not `nonceStarts`, because a parallel
worker's fixed prefix is constant too. This is legal only because the payload length
is constant within one `search()` pass (the nonce only grows on exhaustion, which
restarts the pass), so the padding and length suffix don't move.
On the corrected model (`42 + 46n`), for a JVM target:
| | now | with midstate | speedup |
| --- | --- | --- | --- |
| Short note, 8 blocks → 2 | 410 ns | 134 ns | **3.1x** |
| Long note, 11 blocks → 5 | 548 ns | 272 ns | **2.0x** |
The fixed cost is only 42 ns there, so cutting block count is nearly a pure win. That
is the case for doing it in `desktopApp`/`cli`/`geode`.
**Android is the open question, and it turns on one unmeasured number:** Conscrypt's
per-digest JNI cost. OpenJDK pays none (intrinsic over pure Java); Conscrypt pays a
real call. If that overhead is ~200 ns against ~32 ns/block on ARMv8, an 8-block
attempt is 456 ns and a 2-block one is 264 ns — and neither route to a midstate keeps
that win. `MessageDigest` doesn't expose state, so it is either `clone()`-ing a
pre-absorbed digest (keeps the ARM instruction but adds a *second* JNI call) or a
Kotlin compression function in `commonMain` (drops to zero JNI but forfeits the
hardware instruction, at maybe 300–500 ns/block on ART). Whether Conscrypt's digest is
even `Cloneable` is also unverified.
So: measure Conscrypt's fixed per-digest cost on a real device first. That single
number decides whether Android gets the JVM's 3x, roughly nothing, or a regression.
**2. Kill the per-call floor, if there is one.**
If Android does turn out to be JNI-bound, the way past it is to run many attempts
*below* the JNI boundary — one native call that restores the midstate, walks N nonces
and returns a winner. That keeps the ARMv8 instruction *and* pays the call cost once
per million attempts. It needs an NDK toolchain, which `amethyst` does not have today
(`nestsClient` builds native code, so it is not unprecedented). It is the same
batching structure the GPU path needs — and it beats the GPU by keeping the hardware
SHA-256. Conditional on the measurement above; do not build it on the estimate.
**3. `PoWMiner` only uses half the cores.** `minerWorkers = cores / 2` is right while
the user is composing, but a job that has been handed to the foreground service and
is no longer blocking any UI could use more.
**4. Delegate off-device.** `quartz/…/nip90Dvms/eventPowDelegation/` already models
NIP-90 kinds 5970/6970 — hand the template to a DVM with real hardware. That beats
every on-device option and is already specced.
## Advancing `created_at` while mining — SHIPPED
Not a speedup — a correctness fix that this analysis is a prerequisite for, because
it interacts with the midstate. Implemented as described below; `PoWMiner.run/mine`
take an optional `refreshCreatedAt: (() -> Long)?`, covered by
`quartz/…/PoWMinerCreatedAtTest.kt`.
NIP-13: *"It is recommended to update the `created_at` as well during this process."*
We do half of it. `PoWPublishQueue.enqueue(refreshCreatedAtOnStart = …)` and the
anonymous path in `ShortNotePostViewModel` both stamp a fresh `TimeUtils.now()` at
mining **start**, because "a job that waited in the queue (or was restored after a
process death) would otherwise publish visibly in the past". But the mining run itself
is the same problem: 28 bits at a few M h/s is ~a minute, 30 bits several — and
`PoWPolicy.MAX_DIFFICULTY` is 40. A post can still land in the feed minutes stale.
**Where it goes.** `search()` already re-serializes per pass in its
`do { … } while (nextSize < 50)` loop; today a pass only ends on nonce-space
exhaustion. Bound a pass by *either* exhaustion *or* a wall-clock budget (~1 s), and
re-stamp `createdAt` at the top of each pass. PoW search is memoryless, so restarting
a pass throws away no progress.
**API shape.** Don't make `EventTemplate.createdAt` lazy — it is `@Immutable`,
`@Serializable`, and persisted by `PoWJobPersistence`; a field that changes on read
breaks value semantics and the checkpoint format. Pass the miner a clock instead:
`PoWMiner.mine(…, refreshCreatedAt: (() -> Long)? = null)`, null meaning today's
frozen behaviour. The consumers are already correct — `PoWNostrSigner` forwards
`mined.createdAt` to `signer.sign(…)`, and the queue publishes the mined template — so
this is contained inside `PoWMiner` plus one flag at the call sites.
The new parameter goes **last**, after `isActive`, so the trailing-lambda call sites
keep binding their lambda to `isActive`. Kotlin would otherwise silently retarget them
at `refreshCreatedAt`; the `Boolean`/`Long` mismatch makes that a compile error rather
than a bug, but those sites were rewritten to a named `isActive = { … }` anyway.
**"When we can" is already modelled.** Reuse `refreshCreatedAtOnStart`'s predicate
rather than inventing a second one; its comment already states the exclusion —
*"Must stay false for scheduled posts, whose future created_at is intentional."*
Replaceable/addressable kinds (created_at is their conflict-resolution key) and NIP-59
seals/gift wraps (deliberately randomized timestamps) are already excluded upstream by
`PoWPolicy.neverMine` and `kindsToMine`. Clamp with `maxOf(previous, now())` so a
wall-clock step backwards cannot move a post into the past.
**Interaction with the midstate.** `created_at` sits *before* the tags in
`[0,pubkey,created_at,kind,tags,content]`, so every bump invalidates the whole
midstate. That costs one prefix re-absorb (~1 µs) per bump against millions of
attempts between bumps — irrelevant, and it happens at the pass boundary where the
payload is being rebuilt anyway. The digit count of `created_at` is stable (10 digits
until 2286), but re-serializing per pass sidesteps that question entirely.
**Bonus.** A fresh `created_at` is a fresh search space, which makes the
`nextSize += STARTING_NONCE_SIZE` growth loop and its `RuntimeException("Could not
find PoW")` escape hatch effectively dead: one pass over a 5-char nonce is 73⁵ ≈ 2.1e9
candidates, and each bump grants another 2.1e9.
## If we want the GPU experiment anyway
Do it as a measurement, not a feature:
1. `expect fun gpuHashRate(): Double?` in `quartz`, Android actual on `GLES31`,
returning null when compute shaders are unavailable.
2. A minimal SHA-256 compute shader over a fixed 2-block tail with midstate uploaded
as 8 uints, one nonce per invocation, atomically reporting a winner.
3. Add it to `benchmark/…/PoWBenchmark.kt` next to `generatePow` and run both on real
devices (a flagship and a mid-ranger).
Ship it only if the on-device number beats the CPU by enough to pay for the jank —
the analysis above says it will not, but that measurement is the only thing that
settles it, and it cannot be taken in this container (no GPU, no device).
## Reproducing the measurements
The layout table came from a throwaway `quartz` JVM test that serialized templates via
`EventHasherSerializer.fastMakeJsonForId` and located the nonce with
`ByteArray.indexOf`. The corrected cost sweep timed `sha256Into` per size with a
300k-iteration warmup and best-of-5 timing, cross-checked against standalone Java
(`java Sweep.java`) and `openssl speed -evp sha256`. Neither probe was kept — they
printed rather than asserted.
A permanent version belongs in `benchmark/`, where it can be taken on-device. The one
number worth having there is Conscrypt's fixed per-digest cost: subtract the fitted
slope from a 1-block hash, exactly as the `42 + 46n` fit above does. Everything the
Android side of this plan is still undecided about follows from it.