Skip to content

Commit 4e0617e

Browse files
Zix 0.4.x (MDA2AV#875)
* zix 0.4.x-rc1 * zix drop WebSocket (split to zix-ws instead) * zix 0.4.x-rc1 x86_64 musl alpine * zix head comment info * zix: move to 0.4.x-rc2 * trigger action * clearing space * attempt to resolve with retry * ci: retrigger #1 * re-strategize using two source and retry * Attempt rc2 test 2 * make retry 6 * url wrap arround double quote * using git clone over https * finalizing 0.4.x-rc2 * accident junk * preparing 0.4.x * bump: zix 0.4.x * updating meta * correcting/seperation concern * switching dispatch model * ci: retrigger number 1 * ci: retrigger number 1 * ci: retrigger number 1 * ci: retrigger number 1 * ci: retrigger number 2 * Benchmark results: zix --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent fb24166 commit 4e0617e

29 files changed

Lines changed: 260 additions & 182 deletions

frameworks/zix/Dockerfile

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
# syntax=docker/dockerfile:1.7
2-
#
3-
# zix. Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http).
4-
# Shared-nothing EPOLL: each worker runs its own SO_REUSEPORT accept plus
5-
# level-triggered epoll loop. Engine-owned WebSocket echo, large-body drain.
6-
# Built statically against musl so the runtime image is a single binary on scratch.
72

8-
FROM debian:bookworm-slim AS build
9-
ARG ZIG_VERSION=0.16.0
3+
FROM alpine:3.20 AS build
4+
ARG RETRY=6
105
ARG TARGETARCH
11-
ENV DEBIAN_FRONTEND=noninteractive
12-
RUN apt-get update && apt-get install -y --no-install-recommends \
13-
ca-certificates curl xz-utils \
14-
&& rm -rf /var/lib/apt/lists/*
6+
ARG RETRY_DELAY=3
7+
ARG ZIG_VERSION=0.16.0
8+
ARG ZIX_VERSION=0.4.x
9+
RUN apk add --no-cache ca-certificates curl git tar xz
1510

1611
RUN set -eu; \
1712
case "${TARGETARCH:-amd64}" in \
@@ -24,25 +19,51 @@ RUN set -eu; \
2419
mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig
2520
ENV PATH="/opt/zig:${PATH}"
2621

27-
# Vendor zix 0.3.x, separate layer so source-only rebuilds skip the download.
28-
# Strip the top-level directory produced by GitHub's archive. The Http1 raw
29-
# engine work this image needs (engine-owned WebSocket serve + large-body
30-
# drain) must be present on the 0.3.x branch.
31-
RUN mkdir -p /src/vendor/zix && \
32-
curl -fsSL https://github.com/prothegee/zix/archive/refs/heads/0.3.x.tar.gz \
33-
| tar -xz --strip-components=1 -C /src/vendor/zix
22+
# Vendor zix 0.4.x, separate layer so source-only rebuilds skip the fetch.
23+
# The Http1 raw engine work this image needs (large-body drain plus the per-worker
24+
# response cache used by the /json endpoint) must be present on the 0.4.x branch.
25+
# Four ordered attempts before giving up: curl the archive tarball from github then
26+
# codeberg, then a shallow git clone from github then codeberg. The github archive
27+
# redirects to codeload.github.com (which the benchmark runner may not resolve), so
28+
# curl can fall through to the codeberg tarball, and git clone talks to github.com
29+
# and codeberg.org directly as the deeper fallback. RETRY and RETRY_DELAY bound
30+
# every attempt.
31+
RUN set -eu; \
32+
fetch() { \
33+
rm -rf /src/vendor/zix; mkdir -p /src/vendor/zix; \
34+
curl -fsSL --retry ${RETRY} --retry-delay ${RETRY_DELAY} --retry-all-errors "$1" -o /tmp/zix.tar.gz \
35+
&& tar -xz --strip-components=1 -C /src/vendor/zix -f /tmp/zix.tar.gz; \
36+
}; \
37+
clone() { \
38+
attempt=0; \
39+
while [ "${attempt}" -lt "${RETRY}" ]; do \
40+
rm -rf /src/vendor/zix; \
41+
git clone --depth 1 --branch "${ZIX_VERSION}" "$1" /src/vendor/zix && return 0; \
42+
attempt=$((attempt + 1)); \
43+
sleep "${RETRY_DELAY}"; \
44+
done; \
45+
return 1; \
46+
}; \
47+
fetch "https://github.com/prothegee/zix/archive/refs/heads/${ZIX_VERSION}.tar.gz" \
48+
|| { echo "FAILED: curl ${RETRY} times from github" >&2; \
49+
fetch "https://codeberg.org/prothegee/zix/archive/${ZIX_VERSION}.tar.gz" \
50+
|| { echo "FAILED: curl ${RETRY} times from codeberg" >&2; \
51+
clone "https://github.com/prothegee/zix.git" \
52+
|| { echo "FAILED: git clone ${RETRY} times from github" >&2; \
53+
clone "https://codeberg.org/prothegee/zix.git" \
54+
|| { echo "FAILED: git clone ${RETRY} times from codeberg" >&2; exit 1; }; }; }; }
3455

3556
WORKDIR /src
3657
COPY build.zig build.zig.zon ./
3758
COPY src ./src
3859
RUN set -eu; \
3960
case "${TARGETARCH:-amd64}" in \
40-
amd64) ZIG_TARGET=x86_64-linux-musl ;; \
41-
arm64) ZIG_TARGET=aarch64-linux-musl ;; \
61+
amd64) ZIG_TARGET=x86_64-linux-musl; ZIG_CPU=x86_64_v3 ;; \
62+
arm64) ZIG_TARGET=aarch64-linux-musl; ZIG_CPU=baseline ;; \
4263
esac; \
43-
zig build -Dtarget="${ZIG_TARGET}" --release=fast
64+
zig build -Dtarget="${ZIG_TARGET}" -Dcpu="${ZIG_CPU}" --release=fast
4465

45-
FROM debian:bookworm-slim
66+
FROM alpine:3.20
4667
COPY --from=build /src/zig-out/bin/zix /zix
4768
EXPOSE 8080
4869
ENTRYPOINT ["/zix"]

frameworks/zix/meta.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"language": "Zig",
44
"type": "engine",
55
"engine": "zix",
6-
"description": "Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http). Shared-nothing: each worker runs its own SO_REUSEPORT accept plus level-triggered epoll loop and owns its connections. WebSocket echo is engine-owned (frames echoed on readiness, a pipelined burst coalesced into one write), and request bodies larger than the read buffer are drained rather than buffered.",
6+
"description": "Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http). Shared-nothing: each worker runs its own SO_REUSEPORT accept plus level-triggered epoll loop and owns its connections. The /json endpoint serves from the per-worker response cache, and request bodies larger than the read buffer are drained rather than buffered.",
77
"repo": "https://github.com/prothegee/zix",
88
"enabled": true,
99
"tests": [
@@ -12,9 +12,7 @@
1212
"limited-conn",
1313
"json",
1414
"upload",
15-
"static",
16-
"echo-ws",
17-
"echo-ws-pipeline"
15+
"static"
1816
],
1917
"maintainers": ["prothegee"]
2018
}

frameworks/zix/src/dataset.zig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
//! HttpArena: zix
2+
//! zix version: 0.4.x
3+
//!
4+
//! Dataset loader for the /json endpoint.
5+
//!
6+
//! Loads the fixed 50-item benchmark dataset once at startup and pre-renders
7+
//! each item as a JSON object fragment (without the closing brace), so the hot
8+
//! path only appends the per-request total and the closing brace.
9+
110
const std = @import("std");
211

312
pub const ItemCount = 50;

frameworks/zix/src/main.zig

Lines changed: 109 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
//! HttpArena: zix
2+
//! zix version: 0.4.x
3+
//!
4+
//! zix HttpArena HTTP/1.1 entry point.
5+
//!
6+
//! Intent: demonstrate zix.Http1 (URING dispatch model) against the HttpArena
7+
//! HTTP/1.1 benchmark suite (baseline, pipelined, short-lived).
8+
//!
9+
//! Design choices:
10+
//! - rawIntercept: called before any header parsing for each URING request.
11+
//! Handles /pipeline with zero parse overhead (direct byte-match + sink write),
12+
//! direct byte-match before any parsing, avoiding the header scan loop. Routes that fall
13+
//! through are handled by the Router dispatch with full parsing.
14+
//! - Router: comptime route table (StaticStringMap for EXACT, inline for PREFIX).
15+
//! - PIPELINE_RESP: precomputed response bytes; one sink.append per request, no
16+
//! header build overhead.
17+
118
const std = @import("std");
219
const zix = @import("zix");
320
const dataset = @import("dataset.zig");
@@ -8,19 +25,36 @@ const PORT: u16 = 8080;
825
const LISTEN_IP: []const u8 = "::";
926
const DISPATCH_MODEL: zix.Http1.DispatchModel = .EPOLL;
1027
const KERNEL_BACKLOG: u31 = 16 * 1024;
11-
/// 16 KiB read buffer. Requests beyond it (large uploads) are drained by the
12-
/// engine rather than buffered, so the connection stays usable for keep-alive.
13-
const MAX_RECV_BUF: usize = 16 * 1024;
28+
/// 4 KiB per-connection recv buffer (heap-allocated once at accept time).
29+
/// Benchmark requests are under 300 bytes. Halving from 16 KiB cuts the
30+
/// working set from 64 MiB to 16 MiB at 4096c, reducing cache pressure.
31+
/// Large upload bodies are drained by the engine in chunks, so headers
32+
/// (always < 4 KiB) are the only part that needs to fit.
33+
const MAX_RECV_BUF: usize = 4 * 1024;
1434
const MAX_HEADERS: u8 = 16;
1535
const WORKERS: usize = 0;
1636

37+
// Response cache (ADR-036), used by the /json endpoint only. The /json body is
38+
// deterministic in (count, m) and large enough to clear the cache crossover
39+
// (~4 KiB), so a hit replays the full response with zero serialization. The
40+
// other endpoints stay below the crossover (baseline, pipeline, upload) or use
41+
// their own zero-copy sendfile cache (static), so none of them enable it.
42+
const CACHE_MAX_ENTRIES: u32 = 64;
43+
/// Per-slot cap. A /json/50 response is near 12 KiB, so 32 KiB leaves headroom.
44+
const CACHE_MAX_VALUE_BYTES: u32 = 32 * 1024;
45+
/// Freshness window. The dataset is immutable for the process lifetime, so a
46+
/// long TTL means each key is built once and replayed for the whole run.
47+
const CACHE_TTL_MS: u32 = 60 * 1000;
48+
1749
// Data directory, overridable via the ARENA_DATA env var (default /data, the
1850
// container mount point). Lets the same binary run against a local data tree.
1951
var g_static_base: []const u8 = "/data/static/";
2052
var g_static_base_buf: [256]u8 = undefined;
2153

22-
// Per-worker scratch. JSON response (count up to 50) tops out near 12 KiB.
23-
threadlocal var json_buf: [32 * 1024]u8 = undefined;
54+
// Per-worker scratch. The JSON body (count up to 50) tops out near 12 KiB; the
55+
// assembled response (status line + headers + body) sits a little above it.
56+
threadlocal var json_body_buf: [32 * 1024]u8 = undefined;
57+
threadlocal var json_resp_buf: [32 * 1024]u8 = undefined;
2458

2559
// --------------------------------------------------------- //
2660

@@ -53,25 +87,62 @@ fn baselineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.
5387
zix.Http1.writeSimple(fd, 200, "text/plain", out) catch {};
5488
}
5589

90+
// Precomputed response for the pipeline endpoint: one memcpy per request into the
91+
// response sink. No header build overhead on the hot path.
92+
const PIPELINE_RESP: []const u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok";
93+
5694
// GET /pipeline : fixed tiny response, the pipelined-throughput endpoint.
5795
fn pipelineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void {
5896
_ = head;
5997
_ = body;
6098

61-
zix.Http1.writeSimple(fd, 200, "text/plain", "ok") catch {};
99+
zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {};
100+
}
101+
102+
// Raw-request interceptor for the URING dispatch model. Called before any header
103+
// parsing on each inbound request. Handles /pipeline with zero parse overhead:
104+
// byte-matches the path directly on rem, then appends PIPELINE_RESP to the
105+
// coalescing RespSink. Unknown
106+
// routes return null and fall through to the Router dispatch with full parsing.
107+
//
108+
// This is intentional benchmark infrastructure, not general HTTP parsing. It
109+
// exploits knowledge that /pipeline is always a bare GET with no body, so the
110+
// consumed length is always header_end + 4 ("\r\n\r\n").
111+
fn rawIntercept(rem: []const u8, header_end: usize, fd: std.posix.fd_t) ?usize {
112+
// Must start with "GET /p" to qualify for this fast path.
113+
if (rem.len < 24 or rem[0] != 'G' or rem[4] != '/' or rem[5] != 'p') return null;
114+
115+
// "GET /pipeline " is 15 bytes. Verify without scanning the request line.
116+
if (!std.mem.eql(u8, rem[4..15], "/pipeline ")) return null;
117+
118+
zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {};
119+
120+
return header_end + 4;
62121
}
63122

64123
// GET /json/{count}?m=M : render count dataset items, total = price*qty*M.
124+
//
125+
// Response-cache aware. The body is deterministic in (count, m) and big enough
126+
// to clear the cache crossover, so the full response is the ideal cache value.
127+
// The cache key is hash(method, path, query), so every distinct /json/{count}?m=M
128+
// caches under its own slot. A hit skips the whole build loop and replays the
129+
// stored bytes; a miss builds the response and stores it on the way out. When
130+
// the cache is disabled or full the path still works, it just always rebuilds.
65131
fn jsonHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void {
66132
_ = body;
67133

134+
if (zix.Http1.cacheLookup(head)) |cached| {
135+
zix.Http1.fdWriteAll(fd, cached) catch {};
136+
return;
137+
}
138+
68139
const count_str = head.path["/json/".len..];
69140
const count = std.fmt.parseInt(u8, count_str, 10) catch return badRequest(fd);
70141
if (count < 1 or count > dataset.ItemCount) return badRequest(fd);
71142

72143
const m: u64 = if (zix.Http1.queryParam(head, "m")) |s| std.fmt.parseInt(u64, s, 10) catch 1 else 1;
73144

74-
const buf = &json_buf;
145+
const buf = &json_body_buf;
75146
var pos: usize = 0;
76147

77148
pos = appendStr(buf, pos, "{\"items\":[");
@@ -94,7 +165,17 @@ fn jsonHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posi
94165
buf[pos] = '}';
95166
pos += 1;
96167

97-
zix.Http1.writeJson(fd, 200, buf[0..pos]) catch {};
168+
// Assemble the full response so it can be cached and replayed verbatim. The
169+
// header matches the engine's writeJson output (send_date_header is off, so
170+
// there is no time-varying field to freeze in the cache).
171+
const resp = &json_resp_buf;
172+
const hdr = std.fmt.bufPrint(resp, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch {
173+
zix.Http1.writeJson(fd, 200, buf[0..pos]) catch {};
174+
return;
175+
};
176+
@memcpy(resp[hdr.len..][0..pos], buf[0..pos]);
177+
178+
zix.Http1.writeWithCache(fd, head, resp[0 .. hdr.len + pos], CACHE_TTL_MS) catch {};
98179
}
99180

100181
// POST /upload : return the received byte count. The Content-Length header is
@@ -336,44 +417,16 @@ fn staticHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.po
336417

337418
// --------------------------------------------------------- //
338419

339-
// Echo every text/binary frame back. Ping/close are handled by the engine, so
340-
// this only ever sees data frames. Covers both echo and echo-pipelined: the
341-
// engine coalesces a pipelined burst into one write.
342-
fn wsOnFrame(fd: std.posix.fd_t, opcode: u8, payload: []const u8) void {
343-
zix.Http1.WebSocket.send(fd, @enumFromInt(opcode), payload) catch {};
344-
}
345-
346-
// GET /ws : WebSocket upgrade then engine-owned echo.
347-
fn wsHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void {
348-
_ = body;
349-
350-
const upgrade_val = zix.Http1.getHeader(head, "upgrade") orelse "";
351-
const ws_key = zix.Http1.getHeader(head, "sec-websocket-key");
352-
353-
if (!std.ascii.eqlIgnoreCase(upgrade_val, "websocket") or ws_key == null) {
354-
return badRequest(fd);
355-
}
356-
357-
zix.Http1.WebSocket.serve(fd, ws_key.?, wsOnFrame) catch {
358-
zix.Http1.writeSimple(fd, 500, "text/plain", "handshake failed") catch {};
359-
return;
360-
};
361-
}
362-
363-
// --------------------------------------------------------- //
364-
365-
fn dispatch(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void {
366-
const path = head.path;
367-
368-
if (std.mem.eql(u8, path, "/baseline11")) return baselineHandler(head, body, fd);
369-
if (std.mem.eql(u8, path, "/pipeline")) return pipelineHandler(head, body, fd);
370-
if (std.mem.eql(u8, path, "/upload")) return uploadHandler(head, body, fd);
371-
if (std.mem.eql(u8, path, "/ws")) return wsHandler(head, body, fd);
372-
if (std.mem.startsWith(u8, path, "/json/")) return jsonHandler(head, body, fd);
373-
if (std.mem.startsWith(u8, path, "/static/")) return staticHandler(head, body, fd);
374-
375-
notFound(fd);
376-
}
420+
// Comptime route table. EXACT routes use a StaticStringMap (O(1) hash lookup),
421+
// PREFIX routes match on startsWith with a boundary check (longest match wins).
422+
// rawIntercept handles /pipeline before this dispatch is reached for that route.
423+
const Routes = zix.Http1.Router(&[_]zix.Http1.Route{
424+
.{ .path = "/baseline11", .handler = baselineHandler },
425+
.{ .path = "/pipeline", .handler = pipelineHandler },
426+
.{ .path = "/upload", .handler = uploadHandler },
427+
.{ .path = "/json", .handler = jsonHandler, .kind = .PREFIX },
428+
.{ .path = "/static", .handler = staticHandler, .kind = .PREFIX },
429+
});
377430

378431
// --------------------------------------------------------- //
379432

@@ -421,6 +474,10 @@ fn appendInt(out: []u8, pos: usize, n: u64) usize {
421474
// --------------------------------------------------------- //
422475

423476
pub fn main(process: std.process.Init) !void {
477+
// Elevate scheduling priority (setpriority -19). Fails silently when the
478+
// process lacks CAP_SYS_NICE, so no special capability is required for correctness.
479+
_ = std.os.linux.syscall3(.setpriority, 0, 0, @as(usize, @bitCast(@as(isize, -19))));
480+
424481
const data_dir = process.environ_map.get("ARENA_DATA") orelse "/data";
425482
g_static_base = std.fmt.bufPrint(&g_static_base_buf, "{s}/static/", .{data_dir}) catch "/data/static/";
426483

@@ -429,7 +486,7 @@ pub fn main(process: std.process.Init) !void {
429486

430487
g_dataset = try dataset.load(std.heap.smp_allocator, dataset_path);
431488

432-
var server = zix.Http1.Server.init(dispatch, .{
489+
var server = zix.Http1.Server.initRaw(Routes.dispatch, rawIntercept, .{
433490
.io = process.io,
434491
.ip = LISTEN_IP,
435492
.port = PORT,
@@ -438,6 +495,11 @@ pub fn main(process: std.process.Init) !void {
438495
.max_recv_buf = MAX_RECV_BUF,
439496
.max_headers = MAX_HEADERS,
440497
.workers = WORKERS,
498+
.send_date_header = false,
499+
.response_cache = true,
500+
.cache_max_entries = CACHE_MAX_ENTRIES,
501+
.cache_max_value_bytes = CACHE_MAX_VALUE_BYTES,
502+
.cache_ttl_ms = CACHE_TTL_MS,
441503
});
442504
defer server.deinit();
443505

site/data/baseline-4096.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,19 +1627,19 @@
16271627
{
16281628
"framework": "zix",
16291629
"language": "Zig",
1630-
"rps": 379274,
1631-
"avg_latency": "10.80ms",
1632-
"p99_latency": "11.30ms",
1633-
"cpu": "1204.6%",
1634-
"memory": "92MiB",
1630+
"rps": 3654683,
1631+
"avg_latency": "1.12ms",
1632+
"p99_latency": "2.36ms",
1633+
"cpu": "6610.8%",
1634+
"memory": "395MiB",
16351635
"connections": 4096,
16361636
"threads": 64,
16371637
"duration": "5s",
16381638
"pipeline": 1,
1639-
"bandwidth": "37.24MB/s",
1640-
"input_bw": "29.30MB/s",
1639+
"bandwidth": "229.97MB/s",
1640+
"input_bw": "282.32MB/s",
16411641
"reconnects": 0,
1642-
"status_2xx": 1896374,
1642+
"status_2xx": 18273419,
16431643
"status_3xx": 0,
16441644
"status_4xx": 0,
16451645
"status_5xx": 0

0 commit comments

Comments
 (0)