Port mnemonic embedded verbatim across our source files: 8 ~= B (implied infinity B flattened; bake a cake; baby & me) 3 ~= E (backward) 2 ~= N (pivoted 90 degrees) 0 ~= D (flattened) Files touched: - examples/cuda-fanout/gpu-worker.lsp (*worker-port*) - examples/cuda-fanout/bend.lsp (*bend-worker-port*) - examples/cuda-fanout/mock-worker.py (PORT) - examples/cuda-fanout/bench_tiers.py (asm tier fixed port) - examples/cuda-fanout/smoke-bend.lsp + smoke-bend-asm.lsp - examples/cuda-fanout/README.md - www/bend.html (catalog + multi-host text) - Makefile (PORT default + comment) bend.html updates 3090-ai + ai (4090) fleet table to active 2-node mesh on 8320 — qwen moves off ai, bend takes over.
426 lines
16 KiB
Text
426 lines
16 KiB
Text
;;; bend.lsp -- Lisp-smart GPU dispatch primitive.
|
|
;;;
|
|
;;; (bend expr)
|
|
;;; inspects expr at runtime, routes to a registered GPU worker
|
|
;;; when the cost estimator says it's worth the trip, else
|
|
;;; evaluates locally in the original lexical scope.
|
|
;;;
|
|
;;; The decision is per-call, based on what's in the args -- not
|
|
;;; just the op name. Tiny SHAKE on 5 inputs stays local; big
|
|
;;; SHAKE on 5,000,000 inputs bends to a GPU worker.
|
|
;;;
|
|
;;; (bend! expr)
|
|
;;; forces GPU dispatch; raises an error if no worker available.
|
|
;;;
|
|
;;; Wire protocol: length-prefixed S-exp (see wire.lsp). Same shape
|
|
;;; lumbda's fleet workers already speak, so bend reuses the same
|
|
;;; tcp-* + read-from-string + write-to-string primitives every
|
|
;;; lumbda tier already provides.
|
|
;;;
|
|
;;; Per-tier integration is now ZERO new primitives -- bend.lsp
|
|
;;; works on Python, C, and asm tiers as-is.
|
|
|
|
(load "wire.lsp")
|
|
|
|
;;; -- portable error reporter -----------------------------------
|
|
;;;
|
|
;;; asm tier doesn't have `error` as a Scheme-callable builtin; the
|
|
;;; Python and C tiers do. This wraps both so the rest of the file
|
|
;;; stays portable. Displays the message, then either raises (on tiers
|
|
;;; that have it) or returns a tagged failure value.
|
|
|
|
(define (bend-error msg . rest)
|
|
(display ";;; bend ERROR: ") (display msg)
|
|
(for-each (lambda (x) (display " ") (display x)) rest)
|
|
(newline)
|
|
'bend-failure)
|
|
|
|
;;; -- tuning constants ------------------------------------------
|
|
|
|
;; Per-spawn cuda init ~200 ms; daemon-warm RPC ~100 us. Set high
|
|
;; if your workers haven't started their daemon pools yet.
|
|
(define *bend-overhead-ns* 100000) ; 100 us once daemons are warm
|
|
|
|
;; Host throughput for SHAKE256 (cycles ? ns/byte). Comparing
|
|
;; (cost-fn args) against *bend-overhead-ns* picks the route.
|
|
(define *bend-host-ns-per-byte* 2) ; ~500 MB/s on a modern x86
|
|
|
|
;;; -- registry -- what `bend` knows how to ship ------------------
|
|
;;;
|
|
;;; alist of (op-name . cost-fn) where cost-fn takes the literal
|
|
;;; arg list and returns estimated host-side runtime in nanoseconds.
|
|
|
|
(define *bend-gpu-ops* '())
|
|
|
|
(define (bend-register-op! name cost-fn)
|
|
(set! *bend-gpu-ops* (cons (cons name cost-fn) *bend-gpu-ops*)))
|
|
|
|
;; Cost estimator for (cuda-shake-fanout inputs out-bytes).
|
|
|
|
;; Sum of input byte lengths x ns-per-byte.
|
|
|
|
(define (bend-shake-cost args)
|
|
(let* ((inputs (car args))
|
|
(n (length inputs))
|
|
(avg-len (if (= n 0) 0
|
|
(quotient (string-length (car inputs)) 2))))
|
|
(* n avg-len *bend-host-ns-per-byte*)))
|
|
|
|
(bend-register-op! 'cuda-shake-fanout bend-shake-cost)
|
|
|
|
;;; -- worker endpoint -------------------------------------------
|
|
|
|
(define *bend-worker-host* "127.0.0.1")
|
|
;; Port 8320 — BEND mnemonic:
|
|
;; 8 ~= B (implied infinity B flattened; bake a cake; baby & me)
|
|
;; 3 ~= E (backward)
|
|
;; 2 ~= N (pivoted 90 degrees)
|
|
;; 0 ~= D (flattened)
|
|
(define *bend-worker-port* 8320)
|
|
|
|
;; Override our default localhost:8320 endpoint.
|
|
|
|
(define (bend-set-worker! host port)
|
|
(set! *bend-worker-host* host)
|
|
(set! *bend-worker-port* port))
|
|
|
|
;;; -- multi-worker fleet ----------------------------------------
|
|
;;;
|
|
;;; A list of (host . port) pairs. When non-empty, bend-dispatch-to-gpu
|
|
;;; rotates round-robin across our cluster; a failed pick falls forward
|
|
;;; to a peer worker on a tcp-connect error. When empty, dispatcher
|
|
;;; falls back to a single *bend-worker-host* / *bend-worker-port* pair
|
|
;;; (full back-compat with single-host callers).
|
|
;;;
|
|
;;; Set via (bend-set-workers! '(("3090-ai.foxhop.net" . 8320)))
|
|
;;; or environment variable BEND_WORKERS="host:port,host:port".
|
|
;;;
|
|
;;; Production default: 3090-ai only. ai.foxhop.net (4090) is
|
|
;;; reserved for qwen LLM; we do NOT add it to the round-robin by
|
|
;;; default. Multi-host fan-out gets enabled per long-running
|
|
;;; parallel workload — caller opts in explicitly via
|
|
;;; bend-set-workers! or BEND_WORKERS env.
|
|
|
|
(define *bend-workers* '())
|
|
(define *bend-rr-idx* 0)
|
|
|
|
(define (bend-set-workers! lst)
|
|
(set! *bend-workers* lst)
|
|
(set! *bend-rr-idx* 0)
|
|
(set! *worker-health* '()))
|
|
|
|
;;; -- worker-health heartbeat -----------------------------------
|
|
;;;
|
|
;;; *worker-health* is an alist keyed by "host:port" -> a record
|
|
;;; (last-checked-ms status vram-mb)
|
|
;;; status ∈ {ok, down}
|
|
;;;
|
|
;;; bend-pick-worker filters workers whose cached status=down sits
|
|
;;; within *health-down-cooldown-ms*, falls through to a fresh
|
|
;;; (health) probe whenever a cache entry ages past
|
|
;;; *health-cache-ttl-ms*, & ranks healthy peers by free VRAM
|
|
;;; descending so we route long-running jobs to whichever box
|
|
;;; carries the most spare GPU memory.
|
|
|
|
(define *worker-health* '())
|
|
(define *health-cache-ttl-ms* 5000)
|
|
(define *health-down-cooldown-ms* 30000)
|
|
(define *health-probe-timeout-ms* 2000)
|
|
|
|
(define (worker-key w)
|
|
(string-append (car w) ":" (number->string (cdr w))))
|
|
|
|
(define (worker-health-get w)
|
|
(let ((e (assoc (worker-key w) *worker-health*)))
|
|
(cond ((eq? e #f) #f) (else (cdr e)))))
|
|
|
|
(define (worker-health-set! w status vram-mb)
|
|
(let ((key (worker-key w))
|
|
(entry (list (current-time-ms) status vram-mb)))
|
|
(set! *worker-health*
|
|
(cons (cons key entry)
|
|
(let drop ((rest *worker-health*))
|
|
(cond
|
|
((null? rest) '())
|
|
((string=? (car (car rest)) key) (drop (cdr rest)))
|
|
(else (cons (car rest) (drop (cdr rest))))))))))
|
|
|
|
(define (worker-mark-down! w)
|
|
(worker-health-set! w 'down 0))
|
|
|
|
(define (worker-mark-ok! w vram-mb)
|
|
(worker-health-set! w 'ok vram-mb))
|
|
|
|
;; Extract (vram-free-mb N) from a (health) response form.
|
|
(define (health-find-vram form-list)
|
|
(let loop ((rest form-list))
|
|
(cond
|
|
((null? rest) 0)
|
|
((and (pair? (car rest)) (eq? (car (car rest)) 'vram-free-mb)
|
|
(pair? (cdr (car rest))) (number? (car (cdr (car rest)))))
|
|
(car (cdr (car rest))))
|
|
(else (loop (cdr rest))))))
|
|
|
|
;; Send (health) over a fresh tcp-connect. Returns vram-free-mb on
|
|
;; success (a number, possibly 0), or symbol 'transport-fail on
|
|
;; failure. Lumbda's (eq? 0 #f) reads #t — we deliberately do NOT
|
|
;; signal failure via #f so a healthy worker reporting 0 free VRAM
|
|
;; (no GPU on host, or VRAM fully consumed by a co-tenant) still
|
|
;; ranks as up; calling code checks with `number?`.
|
|
;;
|
|
;; Backward-compat: a worker without the health handler returns
|
|
;; (error (unknown-op health)); treat that as ok with vram=0 so
|
|
;; older builds still rank as available.
|
|
(define (worker-probe-health-body w)
|
|
(let ((sock (tcp-connect (car w) (cdr w))))
|
|
(cond
|
|
((eq? sock #f) 'transport-fail)
|
|
(else
|
|
(wire-send sock '(health))
|
|
(let ((reply (wire-recv sock)))
|
|
(tcp-close sock)
|
|
(cond
|
|
((eq? reply #f) 'transport-fail)
|
|
((not (pair? reply)) 'transport-fail)
|
|
((eq? (car reply) 'ok)
|
|
(health-find-vram (cdr reply)))
|
|
((and (eq? (car reply) 'error)
|
|
(pair? (cdr reply))
|
|
(pair? (car (cdr reply)))
|
|
(eq? (car (car (cdr reply))) 'unknown-op))
|
|
0)
|
|
(else 'transport-fail)))))))
|
|
|
|
;; tcp-connect raises a Python ConnectionRefusedError / gaierror on
|
|
;; dead-host probes. lumbda's `guard` only catches LispErr; we use
|
|
;; with-exception-handler which catches both LispErr & Python
|
|
;; Exception. Net effect: probing a single dead worker never
|
|
;; aborts iteration over our fleet.
|
|
(define (worker-probe-health w)
|
|
(with-exception-handler
|
|
(lambda (exn) 'transport-fail)
|
|
(lambda () (worker-probe-health-body w))))
|
|
|
|
(define (worker-cache-fresh? entry now)
|
|
(let ((age (- now (car entry)))
|
|
(status (car (cdr entry))))
|
|
(cond
|
|
((eq? status 'ok) (< age *health-cache-ttl-ms*))
|
|
((eq? status 'down) (< age *health-down-cooldown-ms*))
|
|
(else #f))))
|
|
|
|
;; #t if worker is reachable & not in down-cooldown. Refreshes cache
|
|
;; lazily when a probe is needed.
|
|
(define (worker-healthy? w)
|
|
(let* ((entry (worker-health-get w))
|
|
(now (current-time-ms)))
|
|
(cond
|
|
((and entry (worker-cache-fresh? entry now))
|
|
(eq? (car (cdr entry)) 'ok))
|
|
(else
|
|
(let ((vram (worker-probe-health w)))
|
|
(cond
|
|
((number? vram) (worker-mark-ok! w vram) #t)
|
|
(else (worker-mark-down! w) #f)))))))
|
|
|
|
(define (worker-cached-vram w)
|
|
(let ((entry (worker-health-get w)))
|
|
(cond ((eq? entry #f) 0) (else (car (cdr (cdr entry)))))))
|
|
|
|
;; In-place insertion sort by cached-vram descending.
|
|
(define (workers-rank-by-vram lst)
|
|
(let outer ((rest lst) (acc '()))
|
|
(cond
|
|
((null? rest) acc)
|
|
(else
|
|
(let ((w (car rest)))
|
|
(outer (cdr rest)
|
|
(let inner ((sorted acc))
|
|
(cond
|
|
((null? sorted) (list w))
|
|
((>= (worker-cached-vram w)
|
|
(worker-cached-vram (car sorted)))
|
|
(cons w sorted))
|
|
(else (cons (car sorted) (inner (cdr sorted))))))))))))
|
|
|
|
(define (workers-filter-healthy lst)
|
|
(let loop ((rest lst) (acc '()))
|
|
(cond
|
|
((null? rest) (reverse acc))
|
|
((worker-healthy? (car rest))
|
|
(loop (cdr rest) (cons (car rest) acc)))
|
|
(else (loop (cdr rest) acc)))))
|
|
|
|
;; Pick the healthiest worker (highest free VRAM). If every worker
|
|
;; is in down-cooldown, fall back to round-robin so a recovering
|
|
;; box gets a real attempt after cooldown — better than refusing
|
|
;; to dispatch at all.
|
|
(define (bend-pick-worker)
|
|
(let* ((healthy (workers-filter-healthy *bend-workers*))
|
|
(n (length healthy)))
|
|
(cond
|
|
((= n 0)
|
|
(let* ((all-n (length *bend-workers*))
|
|
(idx (if (= all-n 0) 0 (remainder *bend-rr-idx* all-n))))
|
|
(set! *bend-rr-idx* (+ *bend-rr-idx* 1))
|
|
(list-ref *bend-workers* idx)))
|
|
((= n 1) (car healthy))
|
|
(else (car (workers-rank-by-vram healthy))))))
|
|
|
|
;; Parse "host:port,host:port" into a list of (host . port) pairs. Skips
|
|
;; malformed entries silently rather than raising — keeps boot path resilient.
|
|
(define (bend-parse-workers-env s)
|
|
(let loop ((rest s) (acc '()) (cur ""))
|
|
(cond
|
|
((= (string-length rest) 0)
|
|
(let ((parsed (bend-parse-one-worker cur)))
|
|
(reverse (if parsed (cons parsed acc) acc))))
|
|
((string=? (substring rest 0 1) ",")
|
|
(let ((parsed (bend-parse-one-worker cur)))
|
|
(loop (substring rest 1 (string-length rest))
|
|
(if parsed (cons parsed acc) acc) "")))
|
|
(else
|
|
(loop (substring rest 1 (string-length rest)) acc
|
|
(string-append cur (substring rest 0 1)))))))
|
|
|
|
(define (bend-parse-one-worker s)
|
|
;; Split on first ':'; return (host . port-number) or #f if malformed.
|
|
(let loop ((i 0))
|
|
(cond
|
|
((>= i (string-length s)) #f)
|
|
((string=? (substring s i (+ i 1)) ":")
|
|
(let ((host (substring s 0 i))
|
|
(port (string->number (substring s (+ i 1) (string-length s)))))
|
|
(cond
|
|
((or (= (string-length host) 0) (eq? port #f)) #f)
|
|
(else (cons host port)))))
|
|
(else (loop (+ i 1))))))
|
|
|
|
;; Probe TCP connect to the next pick OR the legacy single host.
|
|
;; Returns #t/#f without raising.
|
|
|
|
(define (bend-worker-available?)
|
|
(let ((target (cond
|
|
((null? *bend-workers*)
|
|
(cons *bend-worker-host* *bend-worker-port*))
|
|
(else (car *bend-workers*))))) ; cheap reachability probe
|
|
(let ((sock (tcp-connect (car target) (cdr target))))
|
|
(cond
|
|
((eq? sock #f) #f)
|
|
(else (tcp-close sock) #t)))))
|
|
|
|
;; Open a fresh connection, send our form framed, read framed reply,
|
|
|
|
;; close. Returns our result from the worker, or raises if the worker
|
|
|
|
;; responded with (bend-error...).
|
|
|
|
(define (bend-dispatch-to-gpu quoted-form)
|
|
(let* ((target (cond
|
|
((null? *bend-workers*)
|
|
(cons *bend-worker-host* *bend-worker-port*))
|
|
(else (bend-pick-worker))))
|
|
(sock (tcp-connect (car target) (cdr target))))
|
|
(cond
|
|
((eq? sock #f)
|
|
;; Worker unreachable — mark down so the next pick skips it
|
|
;; for *health-down-cooldown-ms* before re-probing.
|
|
(cond ((not (null? *bend-workers*)) (worker-mark-down! target)))
|
|
(bend-error "bend: tcp-connect failed to" target))
|
|
(else
|
|
(wire-send sock quoted-form)
|
|
(let ((reply (wire-recv sock)))
|
|
(tcp-close sock)
|
|
(cond
|
|
((eq? reply #f)
|
|
(cond ((not (null? *bend-workers*)) (worker-mark-down! target)))
|
|
(bend-error "bend: worker closed connection"))
|
|
((not (pair? reply)) (bend-error "bend: malformed reply" reply))
|
|
((eq? (car reply) 'ok) (car (cdr reply)))
|
|
((eq? (car reply) 'error)
|
|
(bend-error "bend worker error:" (cdr reply)))
|
|
(else (bend-error "bend: unexpected reply" reply))))))))
|
|
|
|
;; Optional boot-time hook: if BEND_WORKERS is set in env, parse it now.
|
|
;; Safe to call repeatedly; a missing var is a no-op.
|
|
(define (bend-load-workers-from-env!)
|
|
(let ((s (get-environment-variable "BEND_WORKERS")))
|
|
(cond
|
|
((or (eq? s #f) (= (string-length s) 0)) #f)
|
|
(else
|
|
(let ((lst (bend-parse-workers-env s)))
|
|
(cond
|
|
((null? lst) #f)
|
|
(else
|
|
(bend-set-workers! lst)
|
|
(display ";;; bend: loaded ") (display (length lst))
|
|
(display " workers from BEND_WORKERS") (newline)
|
|
#t)))))))
|
|
|
|
;;; -- core dispatcher -------------------------------------------
|
|
|
|
;; thunk: 0-arg lambda that evaluates the form in its original scope.
|
|
|
|
;; quoted-form: literal sexp for inspection / serialization.
|
|
|
|
;; force-gpu?: when #t, must dispatch to GPU or error.
|
|
|
|
(define (bend-dispatch thunk quoted-form force-gpu?)
|
|
(let* ((head (if (pair? quoted-form) (car quoted-form) #f))
|
|
(entry (assoc head *bend-gpu-ops*)))
|
|
(cond
|
|
(force-gpu?
|
|
(if (and entry (bend-worker-available?))
|
|
(bend-dispatch-to-gpu quoted-form)
|
|
(bend-error "bend!: no GPU worker available for op" head)))
|
|
((not entry) (thunk))
|
|
(else
|
|
(let* ((cost-fn (cdr entry))
|
|
(args (cdr quoted-form))
|
|
(est-host-ns (cost-fn args)))
|
|
(cond
|
|
((< est-host-ns *bend-overhead-ns*) (thunk))
|
|
((bend-worker-available?) (bend-dispatch-to-gpu quoted-form))
|
|
(else
|
|
(display "bend: worker unreachable, running local: ")
|
|
(display head) (newline)
|
|
(thunk))))))))
|
|
|
|
;;; -- function form (works on every tier -- no macros needed) ----
|
|
;;;
|
|
;;; (bend-call '(cuda-shake-fanout '("a" "b") 32))
|
|
;;;
|
|
;;; Useful from lumbda's asm tier which doesn't have define-syntax.
|
|
;;; The macro form below is a thin wrapper over this.
|
|
|
|
(define (bend-call quoted-form)
|
|
;; The thunk fallback returns 'no-local-fallback so callers can
|
|
;; pattern-match without needing the asm tier to support (bend-error...).
|
|
(bend-dispatch (lambda () 'no-local-fallback) quoted-form #f))
|
|
|
|
(define (bend!-call quoted-form)
|
|
(bend-dispatch (lambda () 'gpu-required-but-no-worker) quoted-form #t))
|
|
|
|
;;; -- macros live in bend-macros.lsp (Python/C tiers) ----------
|
|
;;; asm tier doesn't have define-syntax; load bend-macros.lsp only on
|
|
;;; tiers that support it. The function-form bend-call / bend!-call
|
|
;;; above is the asm-portable path.
|
|
|
|
;;; -- demo ------------------------------------------------------
|
|
|
|
(define (make-input n)
|
|
(let loop ((i 0) (acc '()))
|
|
(if (= i n) acc
|
|
(loop (+ i 1) (cons "deadbeefcafebabe1234567890abcdef" acc)))))
|
|
|
|
(define (demo)
|
|
;; 3 inputs: cost is tiny (96 bytes), stays local.
|
|
(display "tiny (3 inputs): bend chooses ")
|
|
(let ((r (bend (cuda-shake-fanout '("00" "01" "deadbeef") 32))))
|
|
(display "-> ") (display (length r)) (display " result(s)\n"))
|
|
;; 100k inputs x 16 bytes = 1.6 MB; should bend if worker is up.
|
|
(display "heavy (100k inputs): bend chooses ")
|
|
(let ((r (bend (cuda-shake-fanout (make-input 100000) 32))))
|
|
(display "-> ") (display (length r)) (display " result(s)\n")))
|
|
|
|
;; (demo) ; uncomment after launching gpu-worker.lsp on port 8320 (BEND)
|