diff --git a/c/builtins.c b/c/builtins.c index 28f7778..ce27159 100644 --- a/c/builtins.c +++ b/c/builtins.c @@ -1303,6 +1303,40 @@ static Value bi_flush_port(Value *a, int n, Env *e) { return VAL_VOID; } +/* write-binary-file / read-binary-file: byte-for-byte file I/O, + * Latin-1 encoded in our string representation (1:1 byte mapping). + * Used by gpu-worker.lsp's binary wire mode to bypass S-expression + * serialization on huge payloads. */ +static Value bi_write_binary_file(Value *a, int n, Env *e) { + (void)e; CHECK_ARITY("write-binary-file", 2); + check_string(a[0]); check_string(a[1]); + const char *path = AS_STRING(a[0])->data; + ULString *data = AS_STRING(a[1]); + FILE *f = fopen(path, "wb"); + if (!f) return VAL_FALSE; + size_t wrote = fwrite(data->data, 1, data->len, f); + fclose(f); + return wrote == data->len ? VAL_VOID : VAL_FALSE; +} + +static Value bi_read_binary_file(Value *a, int n, Env *e) { + (void)e; CHECK_ARITY("read-binary-file", 1); + check_string(a[0]); + const char *path = AS_STRING(a[0])->data; + FILE *f = fopen(path, "rb"); + if (!f) return VAL_FALSE; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = ul_malloc(sz + 1); + if (fread(buf, 1, sz, f) != (size_t)sz) { fclose(f); ul_free(buf); return VAL_FALSE; } + fclose(f); + buf[sz] = 0; + Value r = make_string(buf, sz, false); + ul_free(buf); + return r; +} + /* heap-snapshot / heap-restore: asm-only arena primitives. The asm impl * has no GC; these let a server rewind its bump allocator between * requests. Python + C have real GCs — no-ops here so portable .lsp @@ -1988,6 +2022,8 @@ Env *make_global_env(void) { DEF("tcp-close", bi_tcp_close); DEF("spawn-process-stdio", bi_spawn_process_stdio); DEF("flush-port", bi_flush_port); + DEF("write-binary-file", bi_write_binary_file); + DEF("read-binary-file", bi_read_binary_file); DEF("heap-snapshot", bi_heap_snapshot); DEF("heap-restore", bi_heap_restore); DEF("current-time-ms", bi_current_time_ms); diff --git a/examples/cuda-fanout/bench_tiers.py b/examples/cuda-fanout/bench_tiers.py index f7cad81..3a3420e 100644 --- a/examples/cuda-fanout/bench_tiers.py +++ b/examples/cuda-fanout/bench_tiers.py @@ -21,6 +21,7 @@ reader/printer speed, file I/O wrapping, pipe write/flush handling. import os import secrets import socket +import struct import subprocess import sys import time @@ -99,23 +100,44 @@ def stop_worker(proc): proc.kill() -def time_calls(port, payload, n): +def time_calls(port, payload, n, binary=False): """N round-trips, return list of per-call seconds.""" times = [] for _ in range(n): s = socket.socket() s.connect(("127.0.0.1", port)) t0 = time.monotonic() - wire_send(s, payload) - resp = wire_recv(s) + if binary: + hdr = f"{len(payload):08d}".encode("ascii") + s.sendall(hdr + payload) + rh = recv_exact_sock(s, 8) + n_resp = int(rh.decode()) + resp = recv_exact_sock(s, n_resp) + else: + wire_send(s, payload) + resp = wire_recv(s) elapsed = time.monotonic() - t0 s.close() - if resp is None or "(ok" not in resp: - raise RuntimeError(f"bad response: {resp!r}") + if binary: + if not resp or resp[:4] != b"BSHR": + raise RuntimeError(f"bad binary resp: {resp[:80]!r}") + else: + if resp is None or "(ok" not in resp: + raise RuntimeError(f"bad response: {resp!r}") times.append(elapsed) return times +def recv_exact_sock(sock, n): + buf = b"" + while len(buf) < n: + c = sock.recv(n - len(buf)) + if not c: + return None + buf += c + return buf + + def make_payload(n_inputs, input_bytes): inputs = [secrets.token_hex(input_bytes) for _ in range(n_inputs)] parts = ['(cuda-shake-fanout (quote ('] @@ -125,6 +147,17 @@ def make_payload(n_inputs, input_bytes): return "".join(parts) +def make_payload_binary(n_inputs, input_bytes, out_bytes=32): + """Binary wire payload: 'BSHK' + u32 out_bytes + u32 n + n x (u32 len + bytes). + Sent as raw bytes — no hex encoding, no S-expression.""" + blobs = [secrets.token_bytes(input_bytes) for _ in range(n_inputs)] + parts = [b"BSHK", struct.pack("= (string-length payload) 4) + (string=? (substring payload 0 4) "BSHK")) + (handle-binary-shake client payload) + (tcp-close client) #t) (else - (let ((resp (handle-request req))) + (let* ((req (read-from-string payload)) + (resp (handle-request req))) (wire-send client resp) (tcp-close client) #t)))))))) diff --git a/examples/cuda-fanout/wire.lsp b/examples/cuda-fanout/wire.lsp index 6928192..1afa215 100644 --- a/examples/cuda-fanout/wire.lsp +++ b/examples/cuda-fanout/wire.lsp @@ -20,6 +20,26 @@ (define *wire-header-width* 8) +;; wire-send-raw / wire-recv-raw — frame a raw payload string without +;; S-expression serialization. Used by binary mode dispatch where the +;; payload bytes are NOT a Scheme expression. + +(define (wire-send-raw sock payload) + (let* ((plen (string-length payload)) + (header (zero-pad-left (number->string plen) *wire-header-width*))) + (tcp-send sock header) + (tcp-send sock payload))) + +(define (wire-recv-raw sock) + (let ((header (recv-exact sock *wire-header-width*))) + (cond + ((eq? header #f) #f) + (else + (let ((plen (string->number header))) + (cond + ((or (eq? plen #f) (< plen 0)) #f) + (else (recv-exact sock plen)))))))) + ;; Left-pad string s with '0' chars until length = width. (define (zero-pad-left s width) (let ((slen (string-length s))) diff --git a/lumbda.py b/lumbda.py index 95fdacb..923a9bf 100644 --- a/lumbda.py +++ b/lumbda.py @@ -2803,10 +2803,23 @@ def _tcp_recv(sock, n): data = sock.recv(n) except OSError: return False - return data.decode('utf-8', errors='replace') + # latin-1 = 1:1 byte mapping (codepoints 0-255 == bytes 0-255). + # Preserves arbitrary binary payloads for binary wire mode while + # still passing through every ASCII character cleanly. UTF-8 was + # mangling binary data with replacement chars before. + return data.decode('latin-1') def _tcp_send(sock, s): - data = s.encode('utf-8') if isinstance(s, str) else bytes(s) + if isinstance(s, str): + # encode latin-1 to preserve byte values for binary wire mode. + # Falls back to utf-8 for strings containing codepoints >= 256 + # (rare for our protocol but courteous). + try: + data = s.encode('latin-1') + except UnicodeEncodeError: + data = s.encode('utf-8') + else: + data = bytes(s) try: return sock.send(data) except OSError: @@ -2832,6 +2845,19 @@ def _flush_port(port): port.flush() return VOID +def _write_binary_file(path, data): + """Write Latin-1-encoded string to a binary file byte-for-byte. + Used by gpu-worker.lsp's binary wire mode to write portal files + without any encoding round-trip.""" + with open(path, 'wb') as f: + f.write(data.encode('latin-1') if isinstance(data, str) else bytes(data)) + return VOID + +def _read_binary_file(path): + """Read a binary file as a Latin-1 string (1:1 byte mapping).""" + with open(path, 'rb') as f: + return f.read().decode('latin-1') + def _sym_val(x): if not isinstance(x, Symbol): raise LispErr(f'not a symbol: {show(x)}') return x @@ -3426,6 +3452,8 @@ def make_global_env(): d(S('spawn-process-stdio'), lambda a, _: _spawn_process_stdio(_str_val(a[0]), _spawn_args(a))) d(S('flush-port'), lambda a, _: _flush_port(a[0])) + d(S('write-binary-file'), lambda a, _: _write_binary_file(_str_val(a[0]), _str_val(a[1]))) + d(S('read-binary-file'), lambda a, _: _read_binary_file(_str_val(a[0]))) # heap-snapshot/heap-restore are asm-only arena primitives. Python has # real GC so these are no-ops here — they exist only to let portable # .lsp code call them unconditionally. diff --git a/www/index.html b/www/index.html index 96e8b97..5341faa 100644 --- a/www/index.html +++ b/www/index.html @@ -89,22 +89,21 @@ make gpu-worker LUMBDA=asm # smallest footprint

On a single RTX 3090 with a warm daemon, fan-out matched hashlib.shake_256 byte-for-byte and won by 1.5–10× across the workloads we measured. Below the break-even (~100 MB of bulk hash work) host CPU stays faster — the cost estimator picks correctly.

Tier choice for the worker host

-

The CUDA kernel runs inside the leaf binary, so the tier we pick for the worker host only affects wire orchestration (S-expression parse, portal write, pipe to daemon, response format). Measured per-call round-trip on the 3090 (median of N calls per workload, daemon warm, both client & worker on localhost):

+

The CUDA kernel runs inside the leaf binary; what the tier choice affects is wire orchestration. The wire has two modes: S-expression text (the default — hex strings inside a Scheme list) and binary (magic BSHK header + raw bytes, identical layout to the daemon's binary portal). Binary mode bypasses S-expression parsing entirely:

- + - - - - - - - + + + + +
workloadPythonCasm
workloadPy S-expPy binaryC S-expC binary
small (3 × 16 B)1.27 ms0.16 ms0.21 ms
small (100 × 16 B)3.43 ms0.40 ms1.99 ms
medium (1000 × 16 B)23.24 ms2.77 msCLIFF
med (10k × 16 B)218.82 msCLIFFCLIFF
huge (50k × 16 B)1,099 msCLIFFCLIFF
huge (100k × 16 B)2,219 msCLIFFCLIFF
huge (1M × 16 B)23,811 msCLIFFCLIFF
100 × 16 B3.43 ms0.74 ms0.40 ms0.15 ms
1k × 16 B23.24 ms0.76 ms2.77 ms0.22 ms
10k × 16 B218.82 ms1.27 msCLIFF0.88 ms
100k × 16 B2,219 ms10.18 msCLIFF10.35 ms
1M × 16 B23,811 ms159 msCLIFF157 ms
-

Three tiers, three different operating points. C tier wins at small & medium scales (8× faster than Python). asm tier hits 0.21 ms at very small inputs — competitive with C, faster than Python by 6×, with a 70 KB statically linked binary and zero libc. Python tier scales linearly (~22 µs per input) all the way through 1 M inputs after we fixed recv-exact's string-accumulation O(n²) — it just runs slowly on a single core.

-

The CLIFFs are real tier internals: C tier hits a reader payload limit between 1k & 10k inputs; asm tier hits the same kind of limit one order earlier. The CUDA kernel on this 3090 finishes 1 M × 16 B in ~47 ms — three orders of magnitude under any tier's wire cost at this scale, so what we're measuring is parser throughput, not GPU work.

-

The right next move is a binary wire mode between client & worker, parallel to the binary portal mode the daemon already uses between worker & leaf. Until that lands, the practical guidance: use C tier (the make default) for production; switch to asm tier if you want the smaller footprint or run on a constrained host; use Python tier when you need to scale past 1k inputs per call inline.

+

Binary mode wins by 30–200× over S-expression mode at scale. At 1 M × 16 B inputs, C tier binary is 157 ms end-to-end versus 23,811 ms for the S-exp path — a 150× speedup. The CUDA kernel itself on this 3090 runs in ~47 ms; binary wire adds ~110 ms of file I/O + framing on top, a 2.5× multiplier instead of the 500× multiplier the S-exp path imposed.

+

Critically, at huge workloads bend now beats host hashlib: host SHAKE256 over 1 M tiny inputs is ~2 s on a single Python core; bend via binary worker is 157 ms — a 12× speedup of host. The cost estimator in bend.lsp should be updated to know about the binary path so the routing decision picks GPU at this scale instead of staying local.

+

Binary mode lives behind the BSHK magic byte in the wire payload. S-expression callers see no change; binary callers prepend the magic and send raw bytes. See examples/cuda-fanout/bench_tiers.py --binary for the protocol implementation.

+

Three tiers, three operating points (S-expression mode): C tier wins at small & medium scales (8× faster than Python); asm tier hits 0.21 ms at very small inputs (~30% behind C, 6× faster than Python, 70 KB statically linked, zero libc); Python tier scales linearly (~22 µs per input) all the way through 1 M inputs but runs slowly on a single core. The S-exp CLIFFs at 10k (C) and 1k (asm) are tier-internal reader limits — binary mode bypasses them entirely.

The CUDA toolchain stays isolated to the leaf binary the worker spawns. No tier links libcudart; no tier requires nvcc at build time. Asm tier hosts workers through hand-written pipe2 + fork + execve syscalls — no libc anywhere on the chain.

See examples/cuda-fanout/ for the wire contract, daemon protocol, bench data, and per-tier integration sketch.