Skip to content

perf(opencl): ~3x faster grind-keypair on Apple Silicon - #33

Open
TypiCal25 wants to merge 8 commits into
cavemanloverboy:mainfrom
TypiCal25:opencl-apple-silicon
Open

TypiCal25 wants to merge 8 commits into
cavemanloverboy:mainfrom
TypiCal25:opencl-apple-silicon

Conversation

@TypiCal25

@TypiCal25 TypiCal25 commented Sep 28, 2026 •

Copy link
Copy Markdown

Summary

On Apple GPUs, grind-keypair over OpenCL goes from ~14.2M to ~44M keypairs/s on an M3 Max (3.03–3.11× in alternating runs, ~2.8× once the laptop throttles). No CLI, output or key-file changes.

  • The new search runs only when the OpenCL vendor is Apple (or with VANITY_OPENCL_PROFILE=apple); other devices keep main's search kernel.
  • Every device also gets GPU polling at 1 ms instead of 10 ms (all backends), a warning when the CPU rejects a key the GPU reported (previously dropped silently), and some new kernel code in the OpenCL programs that non-Apple devices never run.
  • New vanity gpu-self-test: checks GPU public keys against ed25519-dalek and, with --pattern, the GPU matcher against the CPU check.

Results

MacBook Pro 16" M3 Max (40-core GPU, 48 GB), macOS 14.4, AC power, Apple OpenCL 1.2, Rust 1.97.1. grind-keypair --pattern zzzzzzzzzz --num-gpus 1 --num-cpus 1, builds alternated so throttling hits both:

Pair main @ 4e0f88d this PR ratio
1 14.16 M/s 44.10 M/s 3.11×
2 14.19 M/s 44.07 M/s 3.10×
3 14.18 M/s 43.05 M/s 3.03×
4 14.15 M/s 39.55 M/s 2.79× (throttling)

Under sustained load the PR build settles around 30 M/s; main stays near 14 M/s.

What changed

Field multiplication dominates the search, and on this GPU a 32×32→64 multiply runs at ⅛ the rate of an fp32 FMA (0.82 vs 6.4 ×10¹² ops/s), so most of the work cuts multiplies. Gains are from alternating runs; some were measured while developing on the code before #30.

Commit Change Gain
f261b4c 13-bit signed affine comb: 20 windows × 7 multiplies instead of 52 × 8, a free first window, and a 7.9 MB table built in parallel 2.3×
Field arithmetic on eight 32-bit limbs: 64 widening multiplies instead of ref10's 100. Multiply, square and one comb step stay out of line; inlined, they overflow the instruction cache and the kernel runs 3× slower ~+5%
One-block SHA-512 for the 32-byte seed; batches of 16 keys that keep only their first seed +4–12%, +2.5%
f2aedda Base58 prefix pre-filter: exact ranges over each key's top 64 bits for every pattern and spelling, so nearly every key skips x and the base58 conversion. Keys inside a range still take the existing check +6.3%, +6.4%
479ab69 One field inversion per 128-item work-group instead of per thread (a product tree in local memory) +1.2% here, +3.6–5.9% earlier
5895d84 GPU polling 10 ms → 1 ms (all backends) idle 1.95% → 0.38%
868d273 Karatsuba multiply, opt-in via VANITY_CL_OPTS=-DFE32_KARATSUBA: exact, but 1–2.6% slower here –

Also: 25f556a adds optional tuning variables (VANITY_KP_LOCAL, VANITY_KP_WAVES, VANITY_TARGET_SEC, VANITY_CL_OPTS, VANITY_COMB_W, VANITY_NO_PREFILTER) and VANITY_CL_INFO=1 per-launch logging; 5e768f6 adds gpu-self-test and the rejected-key warning; 92f9989 updates the README.

The new code is in fe32.cl, ge32.cl and keypair32.cl; keypair.cl, ge.cl, doppler.cl and the CUDA kernels are unchanged. Tried and dropped: compiling as Metal (same speed, since Apple's OpenCL compiles through Metal), 14–16-bit windows, longer launches, larger batches and float-based arithmetic.

Correctness

  • Every 32-bit field operation matches ref10 on 1M random inputs plus 65,860 edge and near-2^256 values.
  • gpu-self-test finds 0 mismatches against ed25519-dalek in 2M seeds, and the matcher agrees with the CPU on 38M random keys across 19 pattern sets (43/44-character boundaries, case-insensitive, 1 prefixes, suffixes, multi-pattern).
  • 56 keys from single and multi-pattern grinds verify with solana-keygen, on the Apple path and on main's path (VANITY_OPENCL_PROFILE=portable).
  • cargo test, cargo fmt --check and clippy (default, deploy, opencl, opencl+deploy) are clean. On aarch64 macOS, clippy --all-targets already fails on main with two dead-code errors in src/fast; this PR adds none.

Limits

  • Tuned and measured on one M3 Max. Other Apple chips should be correct (gpu-self-test will say) but may want different defaults.
  • Non-Apple OpenCL devices compile the new, unused code (self-test kernels, preamble types, sha512_32); I've only compiled it with Apple's OpenCL. The path is chosen from the CL_DEVICE_VENDOR string.
  • grind-doppler stays on main's kernels. Separately, it passes the secret seed to print_doppler_result, so its printed .equ EXPECTED_KEY_* constants are the private key's seed; I'll send a fix in its own PR.

Reproducing

cargo build --release --locked --features opencl
./target/release/vanity gpu-self-test --count 2000000 --pattern ab --pattern ...xyz
VANITY_CL_INFO=1 ./target/release/vanity grind-keypair --pattern zzzzzzzzzz --num-cpus 1

The GPU manager slept 10 ms whenever no launch had finished. A keypair
launch targets 0.4 s, so the average 5 ms between a launch finishing and
the next one starting left the GPU idle about 2% of the time. Measured
with OpenCL event profiling on an Apple M3 Max, the idle share between
launches falls from 1.95% to 0.38% at 1 ms. The shutdown drain loop keeps
its 10 ms sleep.
…logging

Tuning the keypair search on a new GPU meant editing constants and
rebuilding. These optional variables override them at run time:

- VANITY_KP_LOCAL, VANITY_KP_WAVES: work-group size and groups per compute
  unit for the keypair kernel
- VANITY_TARGET_SEC: the launch length the iteration count adapts toward
- VANITY_CL_OPTS: extra OpenCL build options, such as -D overrides

VANITY_CL_INFO=1 prints the device, its vendor and the launch geometry,
then, after five warm-up launches, each launch's kernel-only rate from
OpenCL event profiling next to the wall-clock rate since warm-up; the gap
between the two is time the GPU sat idle between launches. Apple reports
profiling timestamps in Mach ticks (41.67 ns on Apple Silicon) rather than
nanoseconds, so the host converts them with mach_timebase_info.
A keypair kernel that computed wrong public keys would grind forever
without saying so: the GPU callback re-derives each reported seed on the
CPU and silently drops it when the key matches no pattern. There was no
way to check the kernel's arithmetic short of waiting for matches.

`vanity gpu-self-test` derives the public keys of random seeds on GPU 0
with the search kernel's comb table and compares each with ed25519-dalek.
Given --pattern (and --case-insensitive), it also runs the search kernel's
matcher on random 32-byte keys and compares every verdict with the CPU
check grind-keypair applies to GPU results. It exits non-zero on any
difference. The kernels behind it live in keypair_selftest.cl, compiled
into the keypair program; keypair.cl is unchanged.

The GPU callback now prints a warning when a reported key matches no
pattern, since the kernel reports only keys it matched.

On an Apple M3 Max, main's kernels pass: 0 mismatches in 500,000 seeds,
and the matcher agrees with the CPU for single, multi-pattern and
case-insensitive sets.
On an Apple M3 Max the OpenCL keypair search ran at about 14M keys/s.
Almost all of that time is field multiplication, and Apple GPUs run a
32x32->64 widening multiply at an eighth of the rate of a float FMA
(0.82 T/s against 6.4 T/s on the 40-core M3 Max), so the path is rebuilt
around doing fewer of them:

- A 13-bit signed comb over affine multiples (ge32.cl): 20 windows at 7
  multiplies each (ref10's mixed addition) instead of 52 radix-32 windows
  at 8. Window 0 stores (x, y, xy) so it loads with no multiplies, and the
  last window stops before T, which encoding never reads: about 132
  multiplies per key instead of 416. The 7.9 MB table is built by two
  kernels, the window bases on one work-item and then one work-item per
  entry, instead of one work-item for the whole table.
- Field arithmetic on eight 32-bit limbs (fe32.cl): 64 widening
  multiplies per multiply instead of ref10's 100. Values stay below 2^256
  with 2^256 = 38 folded back; every carry near 2^256 is exact, and
  subtraction adds 4p so nothing borrows. The multiply and square are
  straight-line and column-ordered, and they stay out of line, as does one
  comb window step: inlined at every call site they overflow the
  instruction cache and the kernel ran at a third of the speed.
- SHA-512 of the 32-byte seed as one fixed block with unrolled rounds
  (sha512_32), instead of the streaming code with byte buffers.
- Batches of 16 keys per inversion, keeping only the batch's first seed;
  a match re-walks the chain to its own seed.

The host picks the path per device: the Apple path when the OpenCL vendor
is Apple, keypair.cl everywhere else, since the new path is tuned and
measured only on an Apple GPU. VANITY_OPENCL_PROFILE=apple|portable
overrides the choice and VANITY_COMB_W sets the window width (2-16).
keypair.cl, ge.cl, doppler.cl and the CUDA kernels are unchanged; the
new code sits in fe32.cl, ge32.cl and keypair32.cl, with sha512_32 and
the new types added to sha512.cl and preamble.cl.

gpu-self-test covers the path through pubkeys_from_seeds32 and
match_keys32. On the M3 Max: 0 mismatches against ed25519-dalek in
2,000,000 seeds; the matcher agrees with the CPU for single,
multi-pattern, suffix-only, case-insensitive and '1' prefix sets; and 56
keys from single and multi-pattern grinds all verify with solana-keygen,
on this path and on the portable one forced with
VANITY_OPENCL_PROFILE=portable.
Every key paid for a full base58 conversion before its prefix was checked:
64 wide multiplies and 16 emulated 64-bit divisions, plus x = X/Z for the
sign bit. Almost every key fails the prefix, and whether a 32-byte key's
base58 form starts with a given prefix is a range test on the key read as
a big-endian number.

The host now turns each pattern's prefix, in every spelling the match LUT
folds together, into exact ranges over a key's top 64 bits, for both
lengths a key without leading zero bytes encodes to (43 and 44
characters), and sorts and merges them. The kernel binary-searches the
ranges with the canonical y alone and computes x and the base58 digits
only for keys inside one. A key inside a range still takes the existing
exact check, so the ranges only have to over-cover: they use at most the
first 10 characters and each pattern's share of 256 spellings, and they
stay correct as patterns leave the active mask. With a suffix-only pattern
or a prefix that may start with '1' (a leading zero byte) there is no
filter and every key takes the existing path. VANITY_NO_PREFILTER=1 turns
it off.

On an Apple M3 Max: +6.4% and +6.3% in alternating runs. The GPU matcher
agrees with the CPU check on 38M random keys across 19 pattern sets:
prefixes that only start 44- or only 43-character addresses, the 43/44
boundary (5Q, 5R), case-insensitive spellings including L, '1' and '11'
prefixes, prefix plus suffix, suffix only, and multi-pattern sets,
including one mixing a normal prefix with a '1' prefix.
With the comb down to about 132 multiplies per key, each work-item's own
batch inversion (254 squarings and 11 multiplies per 16 keys) was about a
tenth of the search: stubbing it out made the kernel 10-14% faster.

Each work-item now multiplies its batch's Z values together, and
fe32_group_invert inverts every item's product at once: a product tree
over the work-group in local memory, work-item 0 inverting the root, and
the tree walked back down, each node handing its children the inverse
times the sibling. That is one inversion per 2,048 keys instead of per 16,
for about three extra multiplies per work-item. The barriers need every
work-item to run the same number of batches, so work-item 0 now decides
once per batch whether the group stops, and a work-item that finds a match
stays in step until then; the host rounds the group size down to a power
of two the kernel can run, and passes 2 * group size * 32 bytes of local
memory.

On an Apple M3 Max this measured +3.6% and +5.9% while developing it on
the pre-multi-pattern code, and +1.2% on average over five alternating
pairs here, with the machine thermally throttled. gpu-self-test and 56
keys from single and multi-pattern grinds all verify.
One Karatsuba level over 128-bit halves cuts fe32_mul from 64 limb
products to 48: L = f0 g0, H = f1 g1, M = (f0 + f1)(g0 + g1), and
f g = L + (M - L - H) 2^128 + H 2^256, with the half sums' carry bits
picked up at limbs 4 and 8 of M. It is exact: it matches ref10 on 1M
random and 65,860 edge and near-2^256 inputs, and gpu-self-test passes
with it.

It is not the default because on an Apple M3 Max it was 1-2.6% slower
in the search kernel than the schoolbook multiply (1.7% faster in a
multiply-only microbenchmark): its extra signed additions and larger body
cost more there than the 16 multiplies it saves. It stays available as
VANITY_CL_OPTS=-DFE32_KARATSUBA for GPUs where the balance differs.
The performance table's OpenCL row said ~15 M keypairs/s for Apple
Silicon; the Apple path measures ~44 M on an M3 Max with the laptop cool
and roughly 30 M once sustained load throttles it. The install section now
says which OpenCL devices get the Apple path, how to override it, what
gpu-self-test checks, and why --num-cpus 1 is usually enough on Apple
Silicon.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant