bend: cuda-secp256k1-bench op — feel the 277x GPU win at 10M scalars

New op-head (cuda-secp256k1-bench N) lets HTTP callers trigger a
massive secp256k1 batched scalar*G workload without uploading the
32*N-byte BSCP payload. Worker generates the random scalars itself
via generate-bscp.py (/dev/urandom in 1 MB chunks), dispatches to
the existing cuda-secp256k1-batched-mul daemon (same daemon the
BSCP wire mode hits), times the GPU kernel, and returns a small
S-expression summarizing the run:

  (ok (n N)
      (gen-ms G)
      (gpu-ms D)
      (gpu-mkeys-per-sec R)
      (cpu-rate-mkeys-per-sec 0.05)   ; libsecp256k1 single-thread ref
      (cpu-est-sec E)
      (speedup-est S)
      (sample-x HEX))

cpu-rate is the textbook libsecp256k1 single-thread number (~50K
scalar*G/sec). cpu-est-sec extrapolates from that without actually
running the CPU baseline — honest because the rate is well-known
and the daemon's GPU rate (~13.83 Mkeys/s on 3090 per Day-3 bench)
is what we measure end-to-end.

Reference numbers expected at 10M scalars on 3090-ai:
  gen-ms       ~3000 (urandom + write 320 MB)
  gpu-ms       ~720
  speedup-est  ~277x  (gpu 13.83 Mkeys/s / cpu 0.05 Mkeys/s)
  cpu-est-sec  ~200   (~3 minutes of CPU work)

Three helpers added: read-binary-file-prefix (peek at the BSCR
header), sample-x-hex (format point.x as 64-char hex), and
generate-bscp-file (spawn the python helper, fail-open on missing
binary). No daemon changes — secp256k1-batch-mul stays unmodified.
This commit is contained in:
russell@unturf.com 2026-06-14 19:44:21 -04:00
parent ec70ddb5ae
commit ef31860b44
No known key found for this signature in database
2 changed files with 218 additions and 0 deletions

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""generate-bscp.py — emit a BSCP secp256k1 batched scalar*G request file.
Usage: generate-bscp.py COUNT OUT_PATH
Writes a BSCP-format binary suitable for secp256k1-batch-mul --binary or
its --daemon process-bin command. Scalars come from /dev/urandom (fast,
hundreds of MB/s); a few may be zero or >= curve order, but the worker
just produces garbage points for those fine for a bench/demo.
Used by gpu-worker.lsp's handle-cuda-secp256k1-bench to construct big
input payloads server-side so HTTP callers can trigger 10M-scale GPU
work without uploading the 320 MB scalar list.
BSCP wire (from secp256k1-batch-mul.cu header):
"BSCP" 4 B magic
u32 op_id 0x01 = scalar*base
u32 n number of scalars
u8[32] base_x base point X, big-endian (G)
u8[32] base_y base point Y, big-endian (G)
u8[n*32] scalars big-endian 256-bit ints
"""
import os
import struct
import sys
# secp256k1 generator G — official curve parameters.
GX = bytes.fromhex(
"79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"
)
GY = bytes.fromhex(
"483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"
)
CHUNK_SCALARS = 32 * 1024 # 1 MB per write
def main():
if len(sys.argv) != 3:
sys.stderr.write(__doc__)
return 1
count = int(sys.argv[1])
out_path = sys.argv[2]
if count <= 0:
sys.stderr.write(f"count must be positive (got {count})\n")
return 1
with open(out_path, "wb") as f:
f.write(b"BSCP")
f.write(struct.pack("<I", 0x01))
f.write(struct.pack("<I", count))
f.write(GX)
f.write(GY)
# Scalars in 1 MB urandom chunks — minimizes syscall count.
full = count // CHUNK_SCALARS
for _ in range(full):
f.write(os.urandom(CHUNK_SCALARS * 32))
remainder = count % CHUNK_SCALARS
if remainder:
f.write(os.urandom(remainder * 32))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -342,6 +342,155 @@
(display wall-ms) (newline)
(list 'error (list 'no-portal portal-path))))))))))
;;; -- op handler -- cuda-secp256k1-bench --------------------------
;;;
;;; (cuda-secp256k1-bench N) — batched scalar*G on N random scalars.
;;;
;;; This is the "feel the GPU win" demo handler. HTTP callers can't
;;; ship a 32*N-byte BSCP payload over the playground bend bridge
;;; for any meaningful N, so the worker generates the input file
;;; itself from os.urandom via examples/cuda-fanout/generate-bscp.py,
;;; dispatches to the existing secp256k1-batched-mul daemon (same
;;; one the BSCP wire mode hits), and returns timing data — not the
;;; N*64-byte output, which would balloon the response.
;;;
;;; Returns (ok (n N) (gen-ms G) (gpu-ms D) (gpu-mkeys-per-sec R)
;;; (cpu-rate-mkeys-per-sec C) (cpu-est-sec E)
;;; (speedup-est S) (sample-x HEX))
;;;
;;; cpu-rate-mkeys-per-sec is the libsecp256k1 single-thread reference
;;; (~0.05 Mkeys/s = 50K scalar*G/sec on a modern x86 core); used to
;;; extrapolate the CPU time without actually running it.
(define *bscp-generator-path*
(string-append (or (get-environment-variable "PWD") ".")
"/generate-bscp.py"))
(define *cpu-secp256k1-rate-mkeys-per-sec* 0.05) ; libsecp256k1 single-thread ref
(define (generate-bscp-file count out-path)
;; Spawn generate-bscp.py COUNT OUT_PATH, drain stdout to EOF.
;; Returns 'ok on success, (error <reason>) on failure.
(cond
((not (file-exists? "/usr/bin/python3"))
(list 'error 'python3-missing))
((not (file-exists? *bscp-generator-path*))
(list 'error (list 'generator-script-missing *bscp-generator-path*)))
(else
(let ((pair (spawn-process-stdio
"/usr/bin/python3"
(list *bscp-generator-path*
(number->string count)
out-path))))
(cond
((eq? pair #f) (list 'error 'spawn-failed))
(else
(close-port (car pair))
(drain-to-eof (cdr pair))
(close-port (cdr pair))
(cond
((file-exists? out-path) 'ok)
(else (list 'error 'no-output)))))))))
(define (handle-cuda-secp256k1-bench args)
(let ((d (daemon-or-error 'cuda-secp256k1-batched-mul)))
(cond
((and (pair? d) (eq? (car d) 'error)) d)
(else
(let* ((daemon d)
(count (car args))
(in-path (gensym-path "/tmp/bend-secp-bench-in" ".bscp"))
(out-path (gensym-path "/tmp/bend-secp-bench-out" ".bscr"))
(t-gen-start (current-time-ms))
(gen-status (generate-bscp-file count in-path))
(gen-ms (- (current-time-ms) t-gen-start)))
(cond
((not (eq? gen-status 'ok))
(if (file-exists? in-path) (delete-file in-path))
(list 'error (list 'bscp-generate-failed gen-status)))
(else
(display ";;; bend RECV cuda-secp256k1-bench n=")
(display count)
(display " gen-ms=") (display gen-ms) (newline)
(let* ((t-gpu-start (current-time-ms))
(status (daemon-process daemon in-path out-path #t))
(gpu-ms (- (current-time-ms) t-gpu-start)))
(cond
((not (eq? status 'ok))
(if (file-exists? in-path) (delete-file in-path))
(if (file-exists? out-path) (delete-file out-path))
(list 'error (list 'daemon-failed (cdr status))))
(else
(let* ((sample (read-binary-file-prefix out-path 76)) ; 4+4+4 hdr + 64 first pt
(sample-hex (sample-x-hex sample))
(gpu-sec (/ gpu-ms 1000.0))
(gpu-rate (cond ((> gpu-sec 0) (/ count gpu-sec 1000000.0))
(else 0)))
(cpu-rate *cpu-secp256k1-rate-mkeys-per-sec*)
(cpu-est-sec (cond ((> cpu-rate 0)
(/ count cpu-rate 1000000.0))
(else 0)))
(speedup (cond ((> gpu-rate 0) (/ gpu-rate cpu-rate))
(else 0))))
(if (file-exists? in-path) (delete-file in-path))
(if (file-exists? out-path) (delete-file out-path))
(display ";;; bend DONE cuda-secp256k1-bench n=")
(display count)
(display " gpu-ms=") (display gpu-ms)
(display " mkeys-s=") (display gpu-rate) (newline)
(list 'ok
(list 'n count)
(list 'gen-ms gen-ms)
(list 'gpu-ms gpu-ms)
(list 'gpu-mkeys-per-sec gpu-rate)
(list 'cpu-rate-mkeys-per-sec cpu-rate)
(list 'cpu-est-sec cpu-est-sec)
(list 'speedup-est speedup)
(list 'sample-x sample-hex)))))))))))))
;; read first N bytes of file as a binary string. Used to peek at the
;; BSCR header + first output point without slurping the whole result
;; file (which is 64 * count bytes for large counts).
(define (read-binary-file-prefix path n)
(let* ((port (open-input-file path))
(acc (read-string-bytes port n)))
(close-port port)
acc))
;; read up to n bytes from input port — falls back through read-char
;; on tiers where bulk read isn't a primitive. Returns a string of
;; whatever was available (may be shorter than n at EOF).
(define (read-string-bytes port n)
(let loop ((i 0) (acc '()))
(cond
((>= i n) (apply string-append (reverse acc)))
(else
(let ((c (read-char port)))
(cond
((eof-object? c) (apply string-append (reverse acc)))
(else (loop (+ i 1) (cons (string c) acc)))))))))
;; Extract the X coordinate of the first output point from a BSCR
;; prefix as a 64-char hex string. BSCR layout: "BSCR"(4) status(4)
;; n(4) point0_x(32) point0_y(32) ... — so x bytes are at offset 12.
;; Returns "(unavailable)" if the prefix is too short.
(define (sample-x-hex bytes)
(cond
((< (string-length bytes) 44) "(unavailable)")
(else
(let loop ((i 12) (end 44) (acc '()))
(cond
((>= i end) (apply string-append (reverse acc)))
(else
(let* ((b (char->integer (string-ref bytes i)))
(hi (quotient b 16))
(lo (remainder b 16)))
(loop (+ i 1) end
(cons (string (hex-digit hi) (hex-digit lo)) acc)))))))))
(define (hex-digit n)
(cond
((< n 10) (integer->char (+ n (char->integer #\0))))
(else (integer->char (+ (- n 10) (char->integer #\a))))))
;;; -- op handler -- health --------------------------------------
;;;
;;; (health) returns
@ -528,6 +677,7 @@
(cond
((eq? op 'cuda-shake-fanout) (handle-cuda-shake-fanout args))
((eq? op 'cuda-sim-ops-bin) (handle-cuda-sim-ops-bin args))
((eq? op 'cuda-secp256k1-bench) (handle-cuda-secp256k1-bench args))
((eq? op 'health) (handle-health))
((eq? op 'ping) (list 'ok 'pong))
(else (list 'error (list 'unknown-op op))))))))