lumbda/examples/cuda-fanout
russell@unturf.com aaa6e9075c
examples/cuda-fanout: bend end-to-end on lumbda Python tier
Wired bend.lsp to lumbda's existing TCP primitives via wire.lsp
(length-prefixed S-exp framing, lifted from
ecdsa/lumbda/fleet/wire.lsp). The (bend …) macro now actually
dispatches: lumbda → tcp-connect → wire-send → wire-recv → result.

End-to-end on the 3090 (mock-worker as gpu-worker stand-in until
spawn-process-stdio lands in lumbda's core):

  λ> (load "smoke-bend.lsp")
  === smoke-bend ===
  1. cost estimator picks local for 3 inputs (cost too small): OK
  2. worker available? #t
  3. bend! (cuda-shake-fanout '("00" "01" "deadbeef") 32):
     (#xb8d01df855... #x94da6280b2... #xfa094fa86e...)

All three hashes byte-identical to hashlib.shake_256.

Files added:
  wire.lsp         — 8-digit-LE length-prefixed S-exp framing
  smoke-bend.lsp   — minimal lumbda-side test
  mock-worker.py   — Python stand-in for gpu-worker.lsp until
                     spawn-process-stdio + flush-port primitives
                     land in lumbda's core

bug fix:
  wire-recv had one missing close-paren; lumbda surfaced it as
  'unclosed (' on load. Fixed in the same commit.

mock-worker.py accepts two request shapes since bend.lsp serializes
(quote (...)) for list literals while the portal format uses
(inputs ...). Tolerating both keeps the wire protocol bend-friendly.

Per-tier integration status:
  Python tier — bend, wire, smoke-test all work ✓
  C tier      — needs: same Scheme files port directly; tcp-* exist;
                spawn-process-stdio still missing for gpu-worker.lsp
  asm tier    — needs: tcp-* exist; spawn-process-stdio requires raw
                fork + pipe + execve in asm; biggest delta vs Python

Open primitive gaps for full cross-tier bend:
  spawn-process-stdio   — for gpu-worker.lsp's daemon pool
  flush-port            — to push daemon stdin
  (current-time-ms      — exists in Python tier; needed in C/asm too)

Once those land, gpu-worker.lsp replaces mock-worker.py and bend
runs cross-tier-identical. The protocol & cost-estimator code in
bend.lsp + wire.lsp need no changes — they speak only the existing
tcp-* + read-from-string + write-to-string primitives every tier
already has.
2026-06-04 19:16:01 -04:00
..
bench.py examples/cuda-fanout: daemon mode (574x faster per-call) + honest bench 2026-06-04 18:43:06 -04:00
bench_binary.py examples/cuda-fanout: bend primitive + binary format + GPU now wins 2026-06-04 18:58:59 -04:00
bench_daemon.py examples/cuda-fanout: daemon mode (574x faster per-call) + honest bench 2026-06-04 18:43:06 -04:00
bend.lsp examples/cuda-fanout: bend end-to-end on lumbda Python tier 2026-06-04 19:16:01 -04:00
DESIGN-go-gpu.md examples/cuda-fanout: daemon mode (574x faster per-call) + honest bench 2026-06-04 18:43:06 -04:00
gpu-worker.lsp examples/cuda-fanout: bend end-to-end on lumbda Python tier 2026-06-04 19:16:01 -04:00
lumbda-call.lsp examples: cuda-fanout reference primitive for cross-tier GPU work 2026-06-04 17:50:48 -04:00
Makefile examples: cuda-fanout reference primitive for cross-tier GPU work 2026-06-04 17:50:48 -04:00
mock-worker.py examples/cuda-fanout: bend end-to-end on lumbda Python tier 2026-06-04 19:16:01 -04:00
README.md examples/cuda-fanout: bend primitive + binary format + GPU now wins 2026-06-04 18:58:59 -04:00
shake256-fanout.cu examples/cuda-fanout: bend primitive + binary format + GPU now wins 2026-06-04 18:58:59 -04:00
smoke-bend.lsp examples/cuda-fanout: bend end-to-end on lumbda Python tier 2026-06-04 19:16:01 -04:00
test_roundtrip.py examples: cuda-fanout reference primitive for cross-tier GPU work 2026-06-04 17:50:48 -04:00
wire.lsp examples/cuda-fanout: bend end-to-end on lumbda Python tier 2026-06-04 19:16:01 -04:00

cuda-fanout — reference CUDA primitive for lumbda

A reference implementation of "fan out a workload across CUDA cores" as a primitive usable from any lumbda tier (Python, C, asm) without linking the CUDA toolchain into lumbda's core build.

License: AGPLv3 (matches lumbda).

Why this shape

Lumbda's strength is tier portability: the same Scheme source runs byte-identically across Python / C / asm implementations. Direct libcudart linkage breaks this — the asm tier has no libc, the C tier gains a hard dependency, and Python's path-of-least-resistance (cupy / pycuda) doesn't match the other tiers' wire format.

Instead, this primitive is structured as a leaf binary that every tier spawns via its existing process-spawn primitive:

            ┌───────── lumbda Python tier ─────────┐
            │ (cuda-shake-fanout '("00" "01") 32)  │
            │      ↓ subprocess.run                │
            │ shake256-fanout in.portal out.portal │
            └──────────────────────────────────────┘

            ┌───────── lumbda C tier ──────────────┐
            │ (cuda-shake-fanout ...)              │
            │      ↓ fork + execve                 │
            │ shake256-fanout in.portal out.portal │
            └──────────────────────────────────────┘

            ┌───────── lumbda asm tier ────────────┐
            │ (cuda-shake-fanout ...)              │
            │      ↓ syscall fork + execve         │
            │ shake256-fanout in.portal out.portal │
            └──────────────────────────────────────┘

Every tier inherits the GPU work without touching its build dependencies. The CUDA toolchain stays isolated to this directory.

Wire contract

Input portal (caller writes):

(cuda-shake-fanout
  (output-bytes 32)
  (inputs
    "deadbeef"
    "00ff00ff"
    ...))

Output portal (binary writes):

(cuda-shake-fanout-result
  (n 2)
  (output-bytes 32)
  (hashes
    "abc..."
    "def...")
  (timing-ms (load 0.4) (launch 0.1) (kernel 12.7) (write 0.3)))

The wire format is plain S-expressions so any tier's portal reader already handles it (see c/portal.c, lumbda.py portal helpers, asm/portal.s).

What's in the box

file role
shake256-fanout.cu CUDA kernel + host driver. Self-contained, no external EC math, just SHAKE256 fan-out.
Makefile nvcc build + make test + make bench
test_roundtrip.py validates output matches hashlib.shake_256 (host reference)
bench.py timing comparison: Python hashlib host loop vs CUDA fan-out at N = 1k / 10k / 100k
lumbda-call.lsp reference Scheme wrapper showing how lumbda would expose this as (cuda-shake-fanout inputs out-bytes) once each tier registers it

How lumbda's core would integrate this

Three things, each ~20 lines per tier:

  1. Stable primitive name & dispatch entry: (cuda-shake-fanout inputs out-bytes) in each tier's primitive table.
  2. Host detection: at startup, check whether the binary exists at a known path (e.g. ~/.local/bin/cuda-shake-fanout or LUMBDA_CUDA_FANOUT_BIN); if not, the primitive raises a clean cuda-not-available error.
  3. The Scheme wrapper in lumbda-call.lsp (or equivalent inlined per tier).

No tier needs nvcc to build. No tier needs libcudart at runtime. The GPU stays an opt-in capability per host.

Generalizing past SHAKE256

shake256-fanout is a deliberately narrow example because SHAKE256 is small, well-known, and easy to validate against hashlib.shake_256. The same pattern extends to any embarrassingly-parallel CUDA workload the lumbda ecosystem wants — propose new primitives per-workload:

  • cuda-shake-fanout — N hashes (this binary)
  • cuda-keccakp-fanout — N raw Keccak permutations (future)
  • cuda-poly1305-fanout — N MACs (future)

Each one ships as its own leaf binary; each one registers under its own primitive name; each one is independently AGPLv3 alongside the rest of the repo.

Build & smoke test

make all                            # compile via nvcc
make test                           # round-trip vs hashlib.shake_256
make bench                          # device vs host timing grid
python3 bench_daemon.py ./shake256-fanout  # daemon vs per-spawn

Measured performance — where the GPU actually wins (and loses)

Setup: RTX 3090 (10,496 CUDA cores, sm_86) + i9-12900K host, Python hashlib.shake_256 as the reference.

Per-spawn mode — kernel-launch overhead dominates

For SHAKE256 specifically, the per-process CUDA context init eats ~200 ms regardless of workload. The actual kernel is 0.140 ms depending on size. So the GPU loses to host hashlib at every size we tested in per-spawn mode:

in_sz       N         total     host ms    dev ms    kernel ms    speedup
32          10,000    320 KB         6        282       0.12       0.02×
32          100,000   3.2 MB        60        322       0.24       0.19×
32          1,000,000 32 MB        606       1570       1.14       0.39×
32          3,000,000 96 MB       1818       4246       3.04       0.43×
1024        1,000     1 MB           3        239       0.25       0.01×
1024        100,000   102 MB       260       4753       1.72       0.05×
16384       10,000    164 MB       343      18638       2.66       0.02×
262144      1,000     262 MB       508     454171      39.07       0.00×

The 454 SECONDS at 262 MB is portal parsing of the 524 MB hex text file — not the kernel (which is 39 ms). At the current S-expression hex wire format, even our biggest kernels are dwarfed by hex-string parsing.

Conclusion: SHAKE256 alone is too light to amortize either init overhead or hex parsing. Host hashlib reads raw bytes and stays unbeatable for this primitive.

Daemon mode — 574× faster per-call

shake256-fanout --daemon keeps the CUDA context alive across many requests. CUDA init pays once at startup; every subsequent call is just kernel + I/O:

workload: 10 calls × 100 inputs × 32 bytes each

  host hashlib loop  :      0.6 ms total (0.06 ms/call)
  per-spawn fanout   :   1825.9 ms total (182.59 ms/call)
  daemon-mode init   :    109.2 ms  (one-time)
  daemon-mode calls  :      3.2 ms total (0.32 ms/call)

  daemon is 574× faster per call than per-spawn
  daemon end-to-end is 16× faster than per-spawn for 10 calls

The 574× factor is the actual win the go-gpu primitive should inherit. Per-spawn mode is a debug tool; daemon mode is the production architecture for workloads doing repeated fan-outs.

Daemon protocol

shake256-fanout --daemon
  → reads commands on stdin, one per line:
    process <in.portal> <out.portal>     → fan-out, write result, "done <out>"
    quit                                 → clean shutdown, "bye"
  → response per command on stdout

This is exactly the interface a (go-gpu …) primitive in lumbda would wrap: the per-tier dispatcher spawns one daemon per GPU host at boot, then routes every go-gpu form's body through the existing daemon's stdin. CUDA init never re-runs while lumbda is up.

When the per-spawn mode IS the right tool

A few cases:

  • One-shot scripts where init time doesn't matter
  • CI pipelines that need fresh state per test
  • Debugging with isolated CUDA contexts

In those cases the per-spawn binary is sufficient and the daemon is overkill.

Binary portal + daemon — GPU wins by 1.510×

Adding a length-prefixed binary input/output format (--binary flag and process-bin daemon command) eliminates the hex-string parse that was eating 99% of wall time at large workloads. With both binary format and daemon mode, every cell of the bench grid flips to a GPU win:

in_sz       N           total    host ms   dev ms  speedup
32          10,000      320 KB        5         1   5.45×
32          100,000     3.2 MB       47         5   9.95×
32          1,000,000   32 MB       470        47  10.11×
1024        100,000     102 MB      177        79   2.24×
16384       10,000      164 MB      231       124   1.86×
262144      1,000       262 MB      363       231   1.57×

Peak 10× at small-input × high-N. This is the natural shape of many crypto protocols (commitments, Fiat-Shamir transforms, PoW search) where each call hashes a small message but you make a lot of them. The lumbda (bend …) primitive should default to binary format under the hood; the S-expression layer is just the friendly client contract.

Binary format wire (in):

u32 LE out_bytes
u32 LE n
for each i: u32 LE len_i | len_i bytes

Binary format wire (out):

u32 LE n | u32 LE out_bytes | n × out_bytes

Workloads that genuinely shine on this architecture

SHAKE256 is too light. Workloads that actually win on GPU pour compute into each thread:

  • Reversible bit-vector simulation (our ecdsa/cuda/sim_gpu.cu does 12.8M ops × 2,295 threads = 30+ billion ops per kernel, 25 s on a 3090) — kernel dominates, init is noise.
  • Many-round Keccak / PBKDF2 / Argon2 at million-iteration counts.
  • Bulk EC scalar mul (we used OpenMP for 13× win on CPU; on GPU the per-thread cost is the same but parallelism scales).

The cuda-shake-fanout example is intentionally small so the build + test cycle is fast. The pattern (leaf binary + portal + daemon) is the contribution.

Provenance

Keccak permutation derived from FIPS 202 reference. The compact distillation pattern (Markku-Juhani O. Saarinen's tiny-sha3, CC0 / public domain) was the starting point and is re-licensed here under AGPLv3 to align with lumbda. The first practical use was in ~/git/www.foxhop.net/ecdsa/cuda/sim_gpu.cu where on-device SHAKE gave a 2.6× memory compression for batched reversible-circuit simulation (writeup in ecdsa/cuda/README.md). This contribution extracts the generic primitive from that workload-specific use.