Skip to content

Perf: slice serve is the slowest of four at concurrency 1 and gains nothing from keep-alive (follow-up to #38) #62

Closed

Description

@hassidan

Test I ran

http_ka.py starts one server per runtime with the same handler (parse ?n=100, loop 100 iterations, return a JSON body with content-length), then drives it with one synchronous Python http.client connection: 200 warm-up requests, then 3,000 timed requests, three trials per runtime and mode. Every response body is validated. Two modes: a new TCP connection per request, and one keep-alive connection.

Servers: slice serve handler.mjs --port=N --workers=1, Bun.serve, Deno.serve, node:http createServer. (With the default worker count Slice measured 6,326 / 8,283 req/s, so --workers=1 is not what limits it.)

http_ka.py
import http.client, json, os, socket, statistics, subprocess, sys, time
T="/tmp/opencode/slice-bench-tools"; S="/home/dan/slice/target/release/slice"
HANDLER='(req)=>{const u=new URL(req.url);const n=Number(u.searchParams.get("n"));let s=0;for(let i=0;i<n;i++)s=(s+i*7)%1000003;const body=JSON.stringify({n,s});return new Response(body,{headers:{"content-type":"application/json","content-length":String(body.length)}});}'
def expected(n):
    s=0
    for i in range(n): s=(s+i*7)%1000003
    return {"n":n,"s":s}
def free_port():
    with socket.socket() as k: k.bind(("127.0.0.1",0)); return k.getsockname()[1]
def servers(port, d):
    files={
     "slice": 'export default {fetch: '+HANDLER+'};\n',
     "bun": f'Bun.serve({{port:{port},hostname:"127.0.0.1",fetch:{HANDLER}}});\n',
     "deno": f'Deno.serve({{port:{port},hostname:"127.0.0.1",onListen(){{}}}}, {HANDLER});\n',
     "node": f'import http from "node:http"; const h={HANDLER}; http.createServer((req,res)=>{{const r=h({{url:"http://localhost"+req.url}});r.text().then(b=>{{res.writeHead(200,{{"Content-Type":"application/json","Content-Length":String(b.length)}});res.end(b);}});}}).listen({port},"127.0.0.1");\n'}
    cmds={"slice":[S,"serve",f"{d}/slice.mjs",f"--port={port}","--workers=1"],"bun":[f"{T}/bun","run",f"{d}/bun.mjs"],"deno":[f"{T}/deno","run","--allow-net=127.0.0.1",f"{d}/deno.mjs"],"node":["node",f"{d}/node.mjs"]}
    return files, cmds
def run(rt, mode, count=3000, n=100):
    d="/tmp/opencode/slice-perf/srv"; os.makedirs(d,exist_ok=True); port=free_port(); files,cmds=servers(port,d)
    open(f"{d}/{rt}.mjs","w").write(files[rt])
    p=subprocess.Popen(cmds[rt],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
    try:
        dl=time.time()+15
        while True:
            try: socket.create_connection(("127.0.0.1",port),0.1).close(); break
            except OSError:
                if time.time()>dl: raise RuntimeError("server did not start")
                time.sleep(0.02)
        want=expected(n); path=f"/?n={n}"
        def one(conn):
            conn.request("GET",path); r=conn.getresponse(); b=r.read()
            if r.status!=200 or json.loads(b)!=want: raise RuntimeError("bad response")
        conn=http.client.HTTPConnection("127.0.0.1",port)
        for _ in range(200):
            if mode=="fresh": conn=http.client.HTTPConnection("127.0.0.1",port)
            one(conn)
            if mode=="fresh": conn.close()
        if mode=="keepalive": conn=http.client.HTTPConnection("127.0.0.1",port)
        lat=[]; t0=time.perf_counter()
        for _ in range(count):
            a=time.perf_counter()
            if mode=="fresh": conn=http.client.HTTPConnection("127.0.0.1",port)
            one(conn)
            if mode=="fresh": conn.close()
            lat.append(time.perf_counter()-a)
        el=time.perf_counter()-t0
        lat.sort(); return count/el, lat[len(lat)//2]*1e3, lat[int(len(lat)*0.99)]*1e3
    finally:
        p.terminate()
        try: p.wait(5)
        except Exception: p.kill()
res={}
for trial in range(3):
    for mode in ("fresh","keepalive"):
        for rt in ("slice","node","bun","deno"):
            res.setdefault((mode,rt),[]).append(run(rt,mode))
print("mode       runtime   req/s (median of 3 trials x 3000 req)   trials                p50 ms   p99 ms")
for mode in ("fresh","keepalive"):
    for rt in ("slice","node","bun","deno"):
        v=res[(mode,rt)]; print(f"{mode:10} {rt:8} {statistics.median(x[0] for x in v):8.0f}   {[round(x[0]) for x in v]!s:22} {statistics.median(x[1] for x in v):7.3f} {statistics.median(x[2] for x in v):8.3f}")

Result (median of 3 trials × 3,000 requests)

Mode Runtime req/s per-trial req/s p50 ms p99 ms
new connection per request Slice v0.10.7 7,451 7451, 7145, 7768 0.123 0.325
new connection per request Node v24.22.2 8,616 8616, 8872, 7951 0.100 0.484
new connection per request Deno 2.9.8 12,048 12048, 9933, 12243 0.076 0.228
new connection per request Bun 1.4.4 12,521 12324, 12834, 12521 0.072 0.225
keep-alive, one connection Slice v0.10.7 7,453 8320, 7453, 7286 0.103 0.311
keep-alive, one connection Node v24.22.2 12,697 12697, 13814, 11657 0.063 0.344
keep-alive, one connection Deno 2.9.8 17,347 17347, 15850, 19739 0.050 0.168
keep-alive, one connection Bun 1.4.4 19,985 19985, 18727, 20648 0.047 0.126

The separate asyncio harness (benchmarks/controlled/run.py, new connection per request, 3 × 1,000 requests) gives the same ordering at concurrency 1 (Slice 2,812; Node 3,381; Deno 4,064; Bun 4,898 req/s) and a tie with Node at concurrency 8 (Slice 5,606; Node 5,582; Deno 5,917; Bun 6,169). On v0.10.5 the concurrency-1 figure was 1,679, so #41 improved it by 67%.

Reading

  • At concurrency 1 Slice is the slowest of the four in both modes: 1.7× behind Node and 2.7× behind Bun on a keep-alive connection.
  • Reusing the connection makes no difference for Slice (7,451 → 7,453 req/s) while the other three gain 47–60%. So the time is not in accept/close; there is a fixed cost of roughly 0.10 ms per request inside the request path that keep-alive does not remove.
  • slice serve runs each request in a sandboxed isolate and the other three do not, so some fixed cost is expected. If ~0.1 ms is that cost, it would be useful to have it stated next to the pipelined throughput figures in tests/reports/latest.md, which measure a regime (8 sockets × 8-deep pipelining) where it is amortised.

Why I am filing it

Same threshold as the JSON issue: slower than every one of Node, Bun and Deno on the same test, with non-overlapping trials.

Environment

  • Slice v0.10.7, commit ea7c70c, cargo +1.96.0 build --release --locked
  • Ubuntu 26.04, Linux 7.0, x86_64, i5-13500H, loopback, machine otherwise idle

Activity

  1. siktec-lab commented on Nov 11, 2026

    @siktec-lab
    Collaborator

    Thanks for the self-contained http_ka.py benchmark and detailed breakdown.

    Analysis of What http_ka.py Hit

    There are three separate factors behind the numbers you measured:

    1. Why fresh vs keepalive looked flat on Linux loopback (7,451 vs 7,453 req/s)

      • slice serve does keep HTTP/1.1 connections open by default (keep_alive = !version.ends_with("1.0") with TCP_NODELAY enabled) and reuses both the worker SliceIsolate and pre-warmed __slice_rc bundle handle across requests.
      • Per-request sandbox/VFS reset (reset_for_request) costs < 1 us (and native Slice.request() / Slice.respond() bridge execution is ~6 us), not 100 us.
      • On Linux loopback, TCP SYN/ACK + close adds only ~12-18 us, which was masked in v0.10.7 by two larger serial costs per request: ~75-85 us of interpreted WinterTC JS wrapper allocations on the server, plus ~40-50 us inside Python http.client's email.parser parsing Slice's 8 response headers (Content-Type, Content-Length, Connection, Server, X-Slice-Tenant, X-Slice-Isolate-Us, X-Slice-Total-Us, X-Slice-Fuel-Used, vs 3-4 headers on Bun/Deno/Node). On hosts where loopback SYN/ACK is heavier (e.g. Windows at ~250 us), http_ka.py jumps from ~2,595 req/s (fresh) to ~7,058 req/s (keepalive), and a raw single-socket synchronous keep-alive client (bypassing http.client header dict parsing, still concurrency 1 with zero pipelining) reaches 12,375 req/s (p50 = 0.068 ms).
    2. Server-side WinterTC hot-path bottlenecks we just eliminated
      Profiling your exact ?n=100 handler highlighted five avoidable per-request costs in Slice's WinterTC JS layer and Boa host dispatch:

      • new URL(req.url) eagerly assigned 12 own properties on every request even when only u.searchParams was accessed.
      • u.searchParams.get("n") eagerly split and URI-decoded the query string into pair arrays.
      • new Response(body, { headers: { "content-type": ..., "content-length": ... } }) allocated a Headers instance and ran Object.keys() + JSON array serialization even for plain header objects.
      • __slice_dispatch_fetch unconditionally accessed globalThis.process.env even on 1-argument fetch(req) handlers, triggering lazy Shard 8 loading.
      • Warm Boa request entry checked has_own_property("__slice_http_method") and re-scanned bundle feature substrings per request.

      We implemented zero-allocation fast paths for all five (FastUrlProto lazy property getters, direct ASCII query scanning in URLSearchParams.get/has, LazyResProto plain-object header fast path, fn.length > 1 guard before touching process.env, and registered_host_groups / loaded_bundle_flags bitmasks on WarmBoaRealm). On your exact ?n=100 handler, median server-side X-Slice-Isolate-Us is now down to 42 us (X-Slice-Total-Us = 43 us), cutting server-side request latency roughly in half.

    3. Remaining gap vs V8/JSC on n=100 loops and benchmark reporting

      • The remaining ~42 us inside the isolate is Boa's bytecode interpreter running the 100-iteration arithmetic loop + JSON.stringify without a JIT, versus V8/JSC executing JIT-compiled machine code after the 200-request warmup. Slice deliberately runs JIT-less (no executable RWX pages) to keep isolate cold start under 2 us, per-tenant footprint at 4 KB, and instruction fuel deterministic.
      • Agreed on benchmark clarity as well: stating concurrency-1 unpipelined keep-alive latency next to pipelined multi-socket throughput makes both regimes transparent.

    The WinterTC hot-path optimizations and regression tests are committed for the upcoming PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions