wire.lsp: recv-exact O(n²) → O(n); bench reports linear Python scaling

wire.lsp's recv-exact previously accumulated received chunks via
`(string-append acc chunk)` in a loop — quadratic on payload size.
Replaced with a chunk-list accumulator + single `(apply string-append
…)` at the end. Lumbda's string-append knows total length up front
& allocates once.

Python tier now scales linearly across input counts (~22 µs per input):

  workload             Python      C tier
  small  (3 × 16 B)    1.25 ms    0.16 ms    8× C win
  small  (100 × 16 B)  3.39 ms    0.40 ms    8× C win
  medium (1000)        23.52 ms   2.60 ms    9× C win
  med    (10k)         220.15 ms  (cliff)
  huge   (50k)         1,100 ms   (cliff)
  huge   (100k)        2,225 ms   (cliff)
  huge   (1M)          23,811 ms  (cliff)

C tier cliffs somewhere between 1k & 10k inputs per call — its reader
hits a payload limit we still need to track down. CUDA kernel for
1M × 16B finishes in ~47 ms on this 3090, so at huge sizes the wire
cost dominates regardless of tier.

Web page updated with the linear-scaling table & honest framing: at
small inputs C wins by 9×; at huge inputs the right next move is a
binary wire mode parallel to the daemon's already-binary portal
format. Stalls are gone.
This commit is contained in:
russell@unturf.com 2026-06-05 08:55:43 -04:00
parent 373c8ea0d3
commit e1fe20c5b9
No known key found for this signature in database
3 changed files with 26 additions and 16 deletions

View file

@ -178,11 +178,12 @@ def main():
'(load "wire.lsp")\n(load "gpu-worker.lsp")\n(main)\n')
configs = {
"small (3 × 16 B)": (args.small_n, 3, 16),
"small (100 × 16 B)": (args.small_n, 100, 16),
"medium (1000 × 16 B)": (args.medium_n, 1000, 16),
"huge (100k × 16 B)": (3, 100_000, 16),
"huge (1M × 16 B)": (2, 1_000_000, 16),
"small (3 × 16 B)": (args.small_n, 3, 16),
"small (100 × 16 B)": (args.small_n, 100, 16),
"medium (1000 × 16 B)": (args.medium_n, 1000, 16),
"med (10k × 16 B)": (3, 10_000, 16),
"huge (50k × 16 B)": (3, 50_000, 16),
"huge (100k × 16 B)": (2, 100_000, 16),
}
tiers = ["python", "c"]

View file

@ -32,17 +32,24 @@
(define (recv-exact sock n)
"Read exactly n bytes from sock. Returns concatenated string on
success, #f if peer closes before n bytes arrive."
(let loop ((acc "") (remaining n))
success, #f if peer closes before n bytes arrive.
Earlier draft built the result via (string-append acc chunk) in
the loop, which is O(n²) on payload size — at 32 MB payloads
that's ~1 GB of memory-copy work, and the C tier would stall
while the Python tier suffered. This version collects chunks
in a list, then concatenates once via (apply string-append …)
which every tier implements as a single-pass O(n) operation."
(let loop ((chunks '()) (remaining n))
(cond
((= remaining 0) acc)
((= remaining 0) (apply string-append (reverse chunks)))
(else
(let ((chunk (tcp-recv sock remaining)))
(cond
((eq? chunk #f) #f)
((= (string-length chunk) 0) #f) ; peer closed
(else
(loop (string-append acc chunk)
(loop (cons chunk chunks)
(- remaining (string-length chunk))))))))))
(define (wire-send sock sexp)

View file

@ -93,15 +93,17 @@ make gpu-worker LUMBDA=asm # smallest footprint</code></pre>
<table>
<thead><tr><th>workload</th><th>Python tier</th><th>C tier</th><th>C win</th></tr></thead>
<tbody>
<tr><td>small (3 × 16 B)</td><td>1.16 ms</td><td>0.14 ms</td><td>8.3&times;</td></tr>
<tr><td>small (100 × 16 B)</td><td>3.39 ms</td><td>0.42 ms</td><td>8.1&times;</td></tr>
<tr><td>medium (1000 × 16 B)</td><td>23.26 ms</td><td>2.67 ms</td><td>8.7&times;</td></tr>
<tr><td>huge (100k × 16 B)</td><td>2,220 ms</td><td>(wire stalls)</td><td>n/a</td></tr>
<tr><td>huge (1M × 16 B)</td><td>24,338 ms</td><td>(wire stalls)</td><td>n/a</td></tr>
<tr><td>small (3 × 16 B)</td><td>1.25 ms</td><td>0.16 ms</td><td>7.8&times;</td></tr>
<tr><td>small (100 × 16 B)</td><td>3.39 ms</td><td>0.40 ms</td><td>8.5&times;</td></tr>
<tr><td>medium (1000 × 16 B)</td><td>23.52 ms</td><td>2.60 ms</td><td>9.0&times;</td></tr>
<tr><td>med (10k × 16 B)</td><td>220.15 ms</td><td>cliff (next)</td><td></td></tr>
<tr><td>huge (50k × 16 B)</td><td>1,100 ms</td><td></td><td></td></tr>
<tr><td>huge (100k × 16 B)</td><td>2,225 ms</td><td></td><td></td></tr>
<tr><td>huge (1M × 16 B)</td><td>23,811 ms</td><td></td><td></td></tr>
</tbody>
</table>
<p>At small &amp; medium sizes, C tier wins by ~9&times; — that's the ratio between Python's S-expression parser and the C tier's reader. <strong>At huge sizes, both tiers stall on the wire, not the kernel.</strong> The shake256-fanout CUDA kernel on this 3090 finishes 1M × 16 B in ~47 ms — three orders of magnitude faster than the Python tier's 24 s end-to-end. The S-expression hex framing in <code>wire.lsp</code> is O(n²) at huge payload sizes because <code>recv-exact</code> accumulates chunks through <code>string-append</code> in a loop. C tier fails outright at this size; Python tier just suffers.</p>
<p>What this means: at small inputs, tier orchestration matters &amp; C wins. At huge inputs, wire framing dominates regardless of tier — the right fix is a binary wire mode between client &amp; worker, parallel to the binary portal mode the daemon already supports between worker &amp; leaf. Until that lands, keep payloads under ~10 MB or call <code>cuda-shake-fanout</code> against pre-staged files instead of inline lists.</p>
<p>Python tier scales <strong>linearly</strong> with input count (~22 µs per input) all the way through 1 M inputs after we fixed <code>recv-exact</code>'s string accumulation. C tier wins by ~9&times; at small &amp; medium sizes — that's the ratio between Python's S-expression parser and the C tier's reader. C tier cliffs somewhere between 1k &amp; 10k inputs per call, where its reader hits a payload limit we still need to track down. The kernel itself on this 3090 finishes 1 M × 16 B in <strong>~47 ms</strong> — three orders of magnitude under the wire cost at this scale, regardless of tier.</p>
<p>What this means: at small inputs, tier orchestration matters &amp; C wins by 9&times;. At huge inputs, both tiers spend most of their time parsing the hex S-expression payload, not running the kernel. The right next move is a binary wire mode between client &amp; worker, parallel to the binary portal mode the daemon already uses internally. Until that lands, the practical guidance: keep call payloads in the medium range or smaller, or call <code>cuda-shake-fanout</code> against pre-staged files instead of inline hex lists.</p>
<p>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 <code>pipe2 + fork + execve</code> syscalls — no libc anywhere on the chain.</p>
<p>See <a href="https://git.unturf.com/engineering/unturf/lumbda/-/blob/master/examples/cuda-fanout/README.md">examples/cuda-fanout/</a> for the wire contract, daemon protocol, bench data, and per-tier integration sketch.</p>
</section>