Skip to content

Commit c9df765

Browse files
feat(turbopg): vendor pg.zig + add io_uring transport (opt-in, Linux)
Vendors karlseguin/pg.zig and its two deps (buffer.zig, metrics.zig) into the repo so we can stage an io_uring transport without a separate fork repo. Adds an opt-in per-connection io_uring path behind a new build flag, and a driver-only A/B bench against Postgres 18 running in apple/container. Vendored paths: zig/pg/ -- karlseguin/pg.zig @ 7605502 (was a git+url dep, now .path) zig/pg-deps-buffer/ -- karlseguin/buffer.zig @ 30f9512 (identical to the previously-cached upstream) zig/pg-deps-metrics/ -- karlseguin/metrics.zig @ 13d8706 + a tiny Zig 0.16 compat fix on metric.zig:368 (@type(.{.int=...}) -> @int(bits, signed)) New build flag in zig/pg/build.zig: -Diouring=true -- switch pg.Conn transport to io_uring on Linux. Default false, no-op elsewhere. Transport (zig/pg/src/stream.zig): * Per-connection std.os.linux.IoUring, 8 SQEs, no SQPOLL * writeAll: IORING_OP_SEND single-shot, submit + copy_cqe * read: IORING_OP_RECV single-shot, submit + copy_cqe * connect path reuses PlainStream.connect (blocking getaddrinfo) * TLS path unchanged; iouring is skipped when has_openssl=true Bench harness (bench/turbopg/): * bench.zig -- N worker threads, each owns one pg.Conn, hot loop of either 'SELECT 1' or a 50-row generate_series, measures rps for BENCH_DURATION seconds * run.sh -- builds blocking + iouring variants, runs N iters each, prints median rps * Containerfile -- debian:bookworm-slim + Zig 0.16.0 aarch64 * RESULTS.md -- captured medians from one local A/B run Results (one local run, apple/container, Linux 6.18.5, 4 threads, 10s per iter, median of 3-5): query=SELECT 1 blocking=14,967 rps iouring=15,178 rps (+1.4%) query=50-row blocking=13,903 rps iouring=13,849 rps (-0.4%) Both deltas are within run-to-run noise. This is expected for a per-op submit+wait: we trade one recv() syscall for two io_uring_enter syscalls, so on a loopback TCP path the per-op cost is roughly even. The real win requires SQPOLL, batched SEND, and a cooperative scheduler so one ring drives many connections -- all explicitly out of scope for this PR (see RESULTS.md). Per AGENTS.md these numbers are not suitable for release notes, framework comparison tables, or marketing copy. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 9231525 commit c9df765

85 files changed

Lines changed: 17855 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bench/turbopg/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Built artifacts and per-iter bench outputs
2+
zig-out/
3+
.zig-cache/
4+
zig-pkg/
5+
results/

bench/turbopg/Containerfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Build + run the turbopg driver-only A/B bench against a Postgres 18
2+
# container. Per AGENTS.md: results from this container are NOT suitable
3+
# for release notes or comparison tables.
4+
FROM debian:bookworm-slim
5+
6+
ENV DEBIAN_FRONTEND=noninteractive
7+
ENV PATH="/opt/zig:${PATH}"
8+
9+
RUN apt-get update && apt-get install -y --no-install-recommends \
10+
ca-certificates curl xz-utils postgresql-client python3 \
11+
&& rm -rf /var/lib/apt/lists/*
12+
13+
# Zig 0.16.0 aarch64-linux (matches host arch in apple/container on M-series).
14+
RUN mkdir -p /opt && cd /opt \
15+
&& curl -fsSL https://ziglang.org/download/0.16.0/zig-aarch64-linux-0.16.0.tar.xz -o zig.tar.xz \
16+
&& tar -xJf zig.tar.xz \
17+
&& mv zig-aarch64-linux-0.16.0 zig \
18+
&& rm zig.tar.xz
19+
20+
WORKDIR /work
21+
22+
COPY run.sh /usr/local/bin/run.sh
23+
RUN chmod +x /usr/local/bin/run.sh
24+
25+
ENTRYPOINT ["/usr/local/bin/run.sh"]

bench/turbopg/RESULTS.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# turbopg / pg.zig — blocking vs io_uring transport A/B
2+
3+
> **Scope:** the only code path that differs between the two builds is
4+
> the `Stream` struct in `zig/pg/src/stream.zig`. Everything else
5+
> (wire-protocol codec, `Conn`, `Pool`, result decoding) is identical.
6+
> The io_uring path is **one ring per connection**, single in-flight
7+
> SQE per `read` / `writeAll` (submit + `copy_cqe`). **No scheduler, no
8+
> SQPOLL, no multi-shot, no registered fds.** The real async win
9+
> needs all of those on top. Per `AGENTS.md`, do not cite these
10+
> numbers in release notes, framework comparison tables, or
11+
> marketing copy.
12+
13+
## Environment
14+
15+
- Apple `container` CLI (two Linux microVMs on macOS, shared vnic)
16+
- DB container: `postgres:18`, trust auth, default shm, `192.168.64.14`
17+
- Bench container: `debian:bookworm-slim` + Zig 0.16.0 aarch64-linux
18+
- Kernel (both VMs): `Linux 6.18.5 aarch64`
19+
- pg.zig build: `-Doptimize=ReleaseFast`
20+
- Workload: N worker threads, each owns one `pg.Conn`, hot loop of a
21+
single query shape for the duration
22+
- Network: container-to-container via the default `container` network
23+
24+
## Variants
25+
26+
| label | build flag | transport used |
27+
|------------|--------------------|--------------------------|
28+
| `blocking` | `-Diouring=false` | `PlainStream` (read / send via libc syscalls) |
29+
| `iouring` | `-Diouring=true` | `IoUringStream` (one ring per conn, `IORING_OP_SEND` / `IORING_OP_RECV`, submit + `copy_cqe`) |
30+
31+
Both variants share the same connect path, auth path, and `Reader`.
32+
33+
## Workloads
34+
35+
| id | SQL | notes |
36+
|----|----------------------------------------------|-------|
37+
| 1 | `SELECT 1` | smallest round trip |
38+
| 2 | `SELECT id FROM generate_series(1, 50) AS id` | 50 rows back per query, ~300 B response |
39+
40+
## Results
41+
42+
4 worker threads, 10 s per run.
43+
44+
### query=1 (`SELECT 1`), median of 3
45+
46+
| variant | median rps | min | max |
47+
|----------|-----------:|--------:|--------:|
48+
| blocking | 14,967.12 | 14,878 | 15,140 |
49+
| iouring | 15,178.03 | 15,127 | 15,230 |
50+
51+
Δ: **+1.4 %**, well within run-to-run noise with n=3.
52+
53+
### query=2 (`SELECT` generate_series(1,50)), median of 5
54+
55+
| variant | median rps | min | max |
56+
|----------|-----------:|--------:|--------:|
57+
| blocking | 13,903.37 | 13,770 | 14,001 |
58+
| iouring | 13,849.02 | 13,752 | 13,929 |
59+
60+
Δ: **−0.4 %**, again within noise.
61+
62+
## What this tells us
63+
64+
1. Per-connection single-SQE io_uring is **roughly a wash** on a
65+
driver that was already blocking-sync. Expected: we trade one
66+
`recv()` syscall for one `io_uring_enter(submit)` +
67+
`io_uring_enter(wait_cqe)`, so the per-op syscall cost is
68+
approximately even. Kernel fastpath for small receives on a local
69+
TCP loop is already very fast.
70+
2. On q=2 (bigger response, more bytes per recv) io_uring trends
71+
slightly slower — consistent with the extra ring bookkeeping
72+
overhead showing up once the per-op cost matters at all.
73+
3. No regressions, no query errors. The abstraction and the ring
74+
plumbing work correctly for the full `Conn` lifetime (connect,
75+
startup, simple query, extended query, close).
76+
77+
## What would actually move the needle
78+
79+
The next items (deliberately **not** in this PR):
80+
81+
- **SQPOLL** so submitting no longer needs an `io_uring_enter`
82+
syscall in the common case.
83+
- **Batched send**: queue up the parse/bind/describe/execute/sync
84+
packets into one `IORING_OP_SEND` instead of the current per-packet
85+
writes.
86+
- **A cooperative scheduler** so one ring drives N connections
87+
concurrently and a waiting query yields the thread rather than
88+
blocking on `copy_cqe`. This is the real win and turns this from a
89+
neutral change into an actual throughput improvement.
90+
91+
## Caveats (read these)
92+
93+
- 3–5 iterations, 10 s each, one client, one DB. Enough to catch
94+
big regressions, not enough to publish percentage claims.
95+
- `postgres:18` with default config, no tuning, `trust` auth.
96+
- Apple `container` runs each container in its own microVM; cross-VM
97+
network adds a real-ish TCP path but results will not match a
98+
co-located production setup.
99+
- No TLS. The io_uring path is plaintext-only in this PR;
100+
`-Dopenssl_lib_name=...` still picks TLS + the old blocking socket.
101+
102+
## Reproducing
103+
104+
```bash
105+
# 1. Start Postgres 18
106+
container run -d --name pg18 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:18
107+
108+
# 2. Build the bench image once
109+
container build -t turbopg-bench \
110+
-f bench/turbopg/Containerfile bench/turbopg
111+
112+
# 3. Find the pg18 IP (field ADDR in `container ls`)
113+
PG_IP=$(container ls | awk '$1=="pg18" {print $6}' | cut -d/ -f1)
114+
115+
# 4. Run
116+
container run --rm -m 4G -c 4 \
117+
-e PGHOST="$PG_IP" \
118+
-e BENCH_QUERY=1 \
119+
-e BENCH_ITERS=5 \
120+
-v "$PWD":/work \
121+
turbopg-bench
122+
```
123+
124+
Override `BENCH_QUERY` (1 or 2), `BENCH_THREADS`, `BENCH_DURATION`,
125+
`BENCH_ITERS` as needed.

bench/turbopg/bench.zig

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
//! Throughput bench for the vendored pg.zig driver.
2+
//!
3+
//! Spawns N worker threads, each running a hot loop of either:
4+
//! * `SELECT 1`
5+
//! * `SELECT generate_series(1,50) AS id`
6+
//!
7+
//! against a single Postgres instance. Each thread owns its own
8+
//! `pg.Conn` (no pool contention, no scheduler), so the only thing
9+
//! varying between the blocking-build and the iouring-build is the
10+
//! stream transport in `zig/pg/src/stream.zig`.
11+
//!
12+
//! Output is plain text:
13+
//!
14+
//! transport=<blocking|iouring> threads=N duration=Ds query=<id>
15+
//! queries=Q rows=R rps=X.YZ
16+
//!
17+
//! Per AGENTS.md, do NOT cite these numbers in release notes.
18+
19+
const std = @import("std");
20+
const pg = @import("pg");
21+
22+
const Args = struct {
23+
host: []const u8 = "127.0.0.1",
24+
port: u16 = 5432,
25+
user: []const u8 = "postgres",
26+
database: []const u8 = "postgres",
27+
threads: usize = 4,
28+
duration_s: u64 = 10,
29+
query_id: u8 = 1,
30+
label: []const u8 = "blocking",
31+
32+
fn fromEnv(_: std.mem.Allocator) !Args {
33+
var a: Args = .{};
34+
if (getenv("PGHOST")) |v| a.host = v;
35+
if (getenv("PGPORT")) |v| a.port = try std.fmt.parseInt(u16, v, 10);
36+
if (getenv("PGUSER")) |v| a.user = v;
37+
if (getenv("PGDATABASE")) |v| a.database = v;
38+
if (getenv("BENCH_THREADS")) |v| a.threads = try std.fmt.parseInt(usize, v, 10);
39+
if (getenv("BENCH_DURATION")) |v| a.duration_s = try std.fmt.parseInt(u64, v, 10);
40+
if (getenv("BENCH_QUERY")) |v| a.query_id = try std.fmt.parseInt(u8, v, 10);
41+
if (getenv("BENCH_LABEL")) |v| a.label = v;
42+
return a;
43+
}
44+
};
45+
46+
fn nowNs() i128 {
47+
var ts: std.posix.timespec = undefined;
48+
_ = std.posix.system.clock_gettime(.MONOTONIC, &ts);
49+
return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec);
50+
}
51+
52+
fn sleepSeconds(secs: u64) void {
53+
var ts: std.posix.timespec = .{ .sec = @intCast(secs), .nsec = 0 };
54+
_ = std.posix.system.nanosleep(&ts, null);
55+
}
56+
57+
fn getenv(key: []const u8) ?[]const u8 {
58+
// libc getenv; each worker thread only reads it, never mutates.
59+
var buf: [128]u8 = undefined;
60+
const key_z = std.fmt.bufPrintZ(&buf, "{s}", .{key}) catch return null;
61+
const raw = std.c.getenv(key_z.ptr) orelse return null;
62+
return std.mem.span(raw);
63+
}
64+
65+
const ThreadStats = struct {
66+
queries: u64 = 0,
67+
rows: u64 = 0,
68+
err_count: u64 = 0,
69+
};
70+
71+
fn workerLoop(
72+
args: *const Args,
73+
stop_flag: *std.atomic.Value(bool),
74+
stats: *ThreadStats,
75+
) !void {
76+
const allocator = std.heap.smp_allocator;
77+
78+
var conn = try pg.Conn.open(allocator, .{
79+
.host = args.host,
80+
.port = args.port,
81+
});
82+
defer conn.deinit();
83+
84+
try conn.auth(.{
85+
.username = args.user,
86+
.database = args.database,
87+
.timeout = 10_000,
88+
});
89+
90+
const sql = switch (args.query_id) {
91+
1 => "SELECT 1",
92+
2 => "SELECT id FROM generate_series(1, 50) AS id",
93+
else => "SELECT 1",
94+
};
95+
96+
while (!stop_flag.load(.acquire)) {
97+
var result = conn.query(sql, .{}) catch |err| {
98+
stats.err_count += 1;
99+
if (stats.err_count > 10) return err;
100+
continue;
101+
};
102+
defer result.deinit();
103+
104+
while (result.next() catch null) |_| {
105+
stats.rows += 1;
106+
}
107+
stats.queries += 1;
108+
}
109+
}
110+
111+
fn workerEntry(
112+
args: *const Args,
113+
stop_flag: *std.atomic.Value(bool),
114+
stats: *ThreadStats,
115+
) void {
116+
workerLoop(args, stop_flag, stats) catch |err| {
117+
std.debug.print("worker error: {s}\n", .{@errorName(err)});
118+
};
119+
}
120+
121+
pub fn main() !void {
122+
const allocator = std.heap.smp_allocator;
123+
124+
const args = try Args.fromEnv(allocator);
125+
126+
std.debug.print(
127+
"[bench] transport={s} threads={d} duration={d}s query={d} host={s}:{d}\n",
128+
.{ args.label, args.threads, args.duration_s, args.query_id, args.host, args.port },
129+
);
130+
131+
var stop_flag = std.atomic.Value(bool).init(false);
132+
133+
const stats = try allocator.alloc(ThreadStats, args.threads);
134+
@memset(stats, .{});
135+
defer allocator.free(stats);
136+
137+
const threads = try allocator.alloc(std.Thread, args.threads);
138+
defer allocator.free(threads);
139+
140+
const t_start = nowNs();
141+
for (threads, 0..) |*t, i| {
142+
t.* = try std.Thread.spawn(.{}, workerEntry, .{ &args, &stop_flag, &stats[i] });
143+
}
144+
145+
sleepSeconds(args.duration_s);
146+
147+
stop_flag.store(true, .release);
148+
for (threads) |t| t.join();
149+
const t_end = nowNs();
150+
151+
var total_q: u64 = 0;
152+
var total_r: u64 = 0;
153+
var total_e: u64 = 0;
154+
for (stats) |s| {
155+
total_q += s.queries;
156+
total_r += s.rows;
157+
total_e += s.err_count;
158+
}
159+
const elapsed_s: f64 = @as(f64, @floatFromInt(t_end - t_start)) / 1e9;
160+
const rps: f64 = @as(f64, @floatFromInt(total_q)) / elapsed_s;
161+
const rows_ps: f64 = @as(f64, @floatFromInt(total_r)) / elapsed_s;
162+
163+
std.debug.print(
164+
"[bench] result transport={s} queries={d} rows={d} errors={d} elapsed={d:.2}s rps={d:.2} rows_ps={d:.2}\n",
165+
.{ args.label, total_q, total_r, total_e, elapsed_s, rps, rows_ps },
166+
);
167+
}

bench/turbopg/build.zig

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const std = @import("std");
2+
3+
pub fn build(b: *std.Build) void {
4+
const target = b.standardTargetOptions(.{});
5+
const optimize = b.standardOptimizeOption(.{});
6+
const iouring = b.option(bool, "iouring", "Use io_uring transport") orelse false;
7+
8+
const pg_dep = b.dependency("pg", .{
9+
.target = target,
10+
.optimize = optimize,
11+
.iouring = iouring,
12+
});
13+
14+
const exe = b.addExecutable(.{
15+
.name = "turbopg-bench",
16+
.root_module = b.createModule(.{
17+
.target = target,
18+
.optimize = optimize,
19+
.root_source_file = b.path("bench.zig"),
20+
.link_libc = true,
21+
.imports = &.{
22+
.{ .name = "pg", .module = pg_dep.module("pg") },
23+
},
24+
}),
25+
});
26+
27+
b.installArtifact(exe);
28+
const run_cmd = b.addRunArtifact(exe);
29+
run_cmd.step.dependOn(b.getInstallStep());
30+
if (b.args) |args| run_cmd.addArgs(args);
31+
const run_step = b.step("run", "Run the bench");
32+
run_step.dependOn(&run_cmd.step);
33+
}

bench/turbopg/build.zig.zon

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.{
2+
.name = .turbopg_bench,
3+
.version = "0.0.0",
4+
.fingerprint = 0x86359a9f291096a0,
5+
.paths = .{""},
6+
.dependencies = .{
7+
.pg = .{
8+
.path = "../../zig/pg",
9+
},
10+
},
11+
}

0 commit comments

Comments
 (0)