From 9482f22d78ffe49a2ef188671e87ec491fc73589 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Wed, 23 Sep 2026 17:28:13 +0300 Subject: [PATCH 1/3] perf: batch store writes, allocate without a system call each, strip release binaries Events kept in a store were written one transaction each, and LMDB syncs on every commit, so storing ran at about 2,300 events a second. They are now held until 512 have passed their checks, a relay has nothing more ready, or the run ends, and written in one transaction with the library's new Store.ingestBatch, then printed: still stored before they are shown. Storing 100,000 events from a local relay now runs at about 16,000 a second. The allocator was page_allocator, which maps pages for every allocation. It is now smp_allocator. Decoding 10,000 npubs went from 135 ms to 17 ms; signing and verifying are about 15 percent faster. Release binaries are built with the new -Dstrip option. The Linux ones carried about 9 MB of debug sections and go from 12 MB to under 3 MB. The nostr pin moves to 0.14.6 for ingestBatch. A test serves 1,200 events through a local relay, two full batches and part of a third, and checks every one is stored and printed; without the final flushes it fails. Closes #23. --- .github/workflows/release.yml | 2 +- build.zig | 5 ++ build.zig.zon | 4 +- src/main.zig | 5 +- src/relayset.zig | 122 +++++++++++++++++++++++++++++++--- src/testrelay.zig | 11 ++- 6 files changed, 133 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fbf4c2..cf790b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: esac rm -rf "out-$target" - zig build -Dtarget="$target" -Doptimize=ReleaseSafe --prefix "out-$target" + zig build -Dtarget="$target" -Doptimize=ReleaseSafe -Dstrip=true --prefix "out-$target" bin="out-$target/bin/deed" [ -s "$bin" ] || { echo "$target produced no binary" >&2; exit 1; } diff --git a/build.zig b/build.zig index afa5b9b..3410a40 100644 --- a/build.zig +++ b/build.zig @@ -11,11 +11,16 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // Release builds pass -Dstrip. Without it a Linux binary carries about + // nine megabytes of debug sections, three quarters of the file. + const strip = b.option(bool, "strip", "Leave debug information out of the binary"); + const exe_mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, .link_libc = true, + .strip = strip, }); exe_mod.addImport("nostr", nostr_dep.module("nostr")); diff --git a/build.zig.zon b/build.zig.zon index 9efc878..b58da04 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,8 +10,8 @@ // themselves later, and "whatever main happened to be that afternoon" // is not that. .nostr = .{ - .url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.14.5.tar.gz", - .hash = "nostr-0.14.5-CMyPzUgLCQBev0r22ht88wNHfIfc6vajpRq-hylCECfB", + .url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.14.6.tar.gz", + .hash = "nostr-0.14.6-CMyPzdIdCQA3NHmWla4isas-_wBilj5IuVrSRCSL7eZ_", }, }, .paths = .{ diff --git a/src/main.zig b/src/main.zig index 33cbd9c..fa30f07 100644 --- a/src/main.zig +++ b/src/main.zig @@ -49,7 +49,10 @@ const usage = ; pub fn main(init: std.process.Init) !void { - const gpa = std.heap.page_allocator; + // A general-purpose allocator, not `page_allocator`: that one maps pages + // for every allocation, so every small string was a system call, and + // decoding a stream of codes spent most of its time in mmap and munmap. + const gpa = std.heap.smp_allocator; var threaded = std.Io.Threaded.init(gpa, .{}); defer threaded.deinit(); diff --git a/src/relayset.zig b/src/relayset.zig index 82ce076..ab13545 100644 --- a/src/relayset.zig +++ b/src/relayset.zig @@ -52,6 +52,43 @@ pub const Outcome = struct { const max_relays = dial.max_relays; +/// Events held for one store write. LMDB syncs to disk on every commit, so +/// writing each event as it arrives costs a sync apiece; a batch costs one. +const batch_max = 512; + +/// Events that have passed every check and wait to be stored, then printed. +/// Each one's message is kept alive until then, because the event borrows it. +const Pending = struct { + msgs: std.ArrayList(message.ParsedRelayMessage) = .empty, + + fn deinit(self: *Pending, gpa: std.mem.Allocator) void { + for (self.msgs.items) |*m| m.deinit(); + self.msgs.deinit(gpa); + } + + /// Stores what is held in one transaction, then prints it. Stored before + /// printed, so a run interrupted partway through still kept what it had + /// already shown. + fn flush(self: *Pending, gpa: std.mem.Allocator, store: *nostr.store.Store, out: *std.Io.Writer, result: *Outcome) !void { + if (self.msgs.items.len == 0) return; + defer { + for (self.msgs.items) |*m| m.deinit(); + self.msgs.clearRetainingCapacity(); + } + var events: [batch_max]nostr.event.Event = undefined; + var outcomes: [batch_max]nostr.store.IngestResult = undefined; + for (self.msgs.items, 0..) |m, i| events[i] = m.value.event.event; + const n = self.msgs.items.len; + store.ingestBatch(gpa, events[0..n], .{}, outcomes[0..n]) catch {}; + for (events[0..n]) |ev| { + const json = try nostr.event.toJson(gpa, ev); + defer gpa.free(json); + try out.print("{s}\n", .{json}); + } + result.events += n; + } +}; + /// Whether an event answers any of the questions this run asked. /// /// A relay is not obliged to be honest about what it sends, and a subscription @@ -141,6 +178,9 @@ pub fn query( var signer = nostr.keys.Signer.init(); defer signer.deinit(); + var pending: Pending = .{}; + defer pending.deinit(gpa); + // `.awake` is this standard library's monotonic clock: it cannot go // backwards when somebody adjusts the system time mid-run, which `.real` // can, and a deadline that can move backwards is not one. @@ -153,13 +193,19 @@ pub fn query( } var any_live = false; + // Set when a relay had nothing ready, so what is held gets written + // and shown now rather than waiting for a batch that is not coming. + var idle = false; for (relays[0..n], 0..) |maybe, i| { const r = maybe orelse continue; if (done[i]) continue; any_live = true; var msg = (r.receiveTimeout(std.Io.Timeout{ .duration = .{ .raw = .fromMilliseconds(opts.poll_ms), .clock = .awake } }) catch |e| switch (e) { - error.Timeout => continue, + error.Timeout => { + idle = true; + continue; + }, else => { // A relay that drops mid-answer is a relay this run does // without. The events it already sent are still good. @@ -171,7 +217,9 @@ pub fn query( done[i] = true; continue; }; - defer msg.deinit(); + // Unless it is held for the store, in which case the batch owns it. + var held = false; + defer if (!held) msg.deinit(); switch (msg.value) { .event => |e| { @@ -192,16 +240,17 @@ pub fn query( } try seen.put(e.event.id, {}); if (opts.store) |s| { - // Written before it is printed, so a run interrupted - // partway through still kept what it had already read. - // `ingest` checks the signature, so a relay cannot put - // something into the store by claiming it. - _ = s.ingest(gpa, e.event, .{}) catch {}; + // Already checked above, so the store is not asked to + // check it again. + try pending.msgs.append(gpa, msg); + held = true; + if (pending.msgs.items.len == batch_max) try pending.flush(gpa, s, out, &result); + } else { + const json = try nostr.event.toJson(gpa, e.event); + defer gpa.free(json); + try out.print("{s}\n", .{json}); + result.events += 1; } - const json = try nostr.event.toJson(gpa, e.event); - defer gpa.free(json); - try out.print("{s}\n", .{json}); - result.events += 1; }, .eose => { result.complete += 1; @@ -219,9 +268,13 @@ pub fn query( .ok => {}, } } + if (opts.store) |st| { + if (idle) try pending.flush(gpa, st, out, &result); + } if (!any_live) break; } + if (opts.store) |st| try pending.flush(gpa, st, out, &result); return result; } @@ -263,6 +316,53 @@ test "a relay that never answers the dial costs the run its deadline, not foreve try std.testing.expect(took < 3_000); } +test "events kept in a store are written in batches, and every one is stored and printed" { + const io = std.testing.io; + const testrelay = @import("testrelay.zig"); + var da: testrelay.DialAllocator = .init; + defer if (da.deinit() == .leak) @panic("the query leaked"); + const gpa = da.allocator(); + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Two full batches and part of a third. + const n = batch_max * 2 + 176; + var signer = try nostr.keys.Signer.initRandomized(io); + defer signer.deinit(); + const kp = try signer.keyPairFromSecretKey([_]u8{0x22} ** 32); + const serving = try arena.alloc([]const u8, n); + for (serving, 0..) |*ev, i| { + const e = try nostr.event.create(arena, signer, kp, 1_700_000_000 + @as(i64, @intCast(i)), 1, &.{}, try std.fmt.allocPrint(arena, "note {d}", .{i}), null); + ev.* = try nostr.event.toJson(arena, e); + } + + var relay_: testrelay.Relay = undefined; + try relay_.start(io, .serve); + relay_.serving = serving; + defer relay_.stop(io); + var buf: [40]u8 = undefined; + const urls = [_][]const u8{try testrelay.url(&buf, relay_.port())}; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp.dir.realPath(io, &dir_buf); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try std.fmt.bufPrintZ(&path_buf, "{s}/store.mdb", .{dir_buf[0..dir_len]}); + var st = try nostr.store.Store.open(path.ptr, .{}); + defer st.deinit(); + + var out: std.Io.Writer.Allocating = .init(arena); + var err: std.Io.Writer.Allocating = .init(arena); + const filters = [_]filter.Filter{.{ .kinds = &.{1} }}; + const outcome = try query(gpa, io, &urls, &filters, &out.writer, &err.writer, .{ .deadline_ms = 10_000, .poll_ms = 50, .store = &st }); + + try std.testing.expectEqual(@as(usize, n), outcome.events); + try std.testing.expectEqual(@as(usize, n), std.mem.count(u8, out.written(), "\n")); + try std.testing.expectEqual(@as(usize, n), try st.eventCount()); +} + test "relays past the most one run dials are named as left out, not dropped quietly" { const io = std.testing.io; const testrelay = @import("testrelay.zig"); diff --git a/src/testrelay.zig b/src/testrelay.zig index fe015c9..a2e25a0 100644 --- a/src/testrelay.zig +++ b/src/testrelay.zig @@ -51,6 +51,8 @@ pub const Mode = enum { pings_deaf, /// Twenty notices, then OK true, then the connection ends. notices_then_close, + /// Answers a REQ with every event in `serving`, then EOSE. + serve, /// Sends a message the parser has no case for before each OK true. unreadable_first, /// Refuses every EVENT with a reason carrying a terminal escape and a @@ -86,6 +88,8 @@ pub const Relay = struct { closing: bool = false, /// Connections served before the task returns. connections: usize = 1, + /// Event JSON the `serve` mode sends in answer to a REQ. + serving: []const []const u8 = &.{}, task: Io.Future(void), /// Starts serving in the background. `self` must not move until `stop`. @@ -210,6 +214,11 @@ pub const Relay = struct { const kind = items[0].string; var text: [512]u8 = undefined; if (std.mem.eql(u8, kind, "REQ")) { + if (self.mode == .serve) for (self.serving) |ev| { + const frame = try std.fmt.allocPrint(gpa, "[\"EVENT\",\"{s}\",{s}]", .{ items[1].string, ev }); + defer gpa.free(frame); + try sendText(w, frame); + }; try sendText(w, try std.fmt.bufPrint(&text, "[\"EOSE\",\"{s}\"]", .{items[1].string})); } else if (std.mem.eql(u8, kind, "EVENT")) { _ = self.events.fetchAdd(1, .monotonic); @@ -262,7 +271,7 @@ pub const Relay = struct { try sendText(w, "[\"COUNT\",\"x\",{\"count\":1}]"); try sendText(w, try std.fmt.bufPrint(&text, "[\"OK\",\"{s}\",true,\"\"]", .{id})); }, - .silent, .chatty, .notice_burst, .pongs, .deaf, .pings_deaf => {}, + .silent, .chatty, .notice_burst, .pongs, .deaf, .pings_deaf, .serve => {}, } } } From a32942d31b2443ab859921fa93688f28eba0ed77 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Wed, 23 Sep 2026 17:31:52 +0300 Subject: [PATCH 2/3] docs: benchmarks, and the script that reproduces them BENCHMARKS.md gives what deed costs to run: binary and download sizes, startup, memory, stream throughput, storing and querying 100,000 events, and relays over loopback. Every number comes from bench/run.py, which runs the same measurements against any build, with a local relay (bench/relay.py behind websocat) so nothing leaves the machine. The README gains a short section pointing to it. --- BENCHMARKS.md | 71 +++++++++++++++++++++++ README.md | 6 +- bench/relay.py | 27 +++++++++ bench/run.py | 149 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 BENCHMARKS.md create mode 100644 bench/relay.py create mode 100644 bench/run.py diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..c5c5a05 --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,71 @@ +# Benchmarks + +What deed costs to run: how big it is, how fast it starts, how much memory it takes, and how fast it signs, verifies, stores and reads events. Every number here comes from [`bench/run.py`](bench/run.py), which you can run against your own build: + +```sh +zig build -Doptimize=ReleaseSafe -Dstrip=true +python3 bench/run.py zig-out/bin/deed +``` + +It needs python3 and [websocat](https://github.com/vi/websocat). Everything happens on your machine: the relay it talks to is [`bench/relay.py`](bench/relay.py) on 127.0.0.1, and nothing is sent anywhere else. Each timing is the best of several runs, because on a shared machine noise only ever adds time. + +Measured on an Apple M2 Pro with macOS 26.6, with deed 0.3.1 built as it is released (`ReleaseSafe`, stripped). + +## Size + +| Release | Binary | Download | +| --- | --- | --- | +| macOS, Apple Silicon | 2.3 MB | 1.6 MB | +| macOS, Intel | 2.4 MB | 1.7 MB | +| Linux, x86_64 | 2.7 MB | 1.8 MB | +| Linux, aarch64 | 2.4 MB | 1.7 MB | + +The Linux binaries are statically linked, so that is everything they need. + +## Starting, and one event at a time + +| | | +| --- | --- | +| starting any process at all (`/usr/bin/true`), for scale | 1.56 ms | +| `deed version` | 2.34 ms | +| `deed key generate` | 2.37 ms | +| `deed decode ` | 2.41 ms | +| `deed event`, signing one | 2.41 ms | +| `deed verify`, one | 3.02 ms | +| peak memory, `deed version` | 1.7 MB | + +deed's own share of a one-shot command is under a millisecond; the rest is the operating system starting a process. Verifying one event adds about 0.6 ms to that. + +## Streams + +| 10,000 records through stdin | | +| --- | --- | +| signing (`deed event -`) | 22,660 per second | +| verifying (`deed verify`) | 33,270 per second | +| decoding npubs (`deed decode`) | 586,718 per second | +| peak memory, verifying 10,000 | 3.8 MB | + +## The local store + +100,000 signed events from 1,000 authors: a profile, a follow list and 98 notes each. + +| | | +| --- | --- | +| storing them from a loopback relay (`req --store`), each signature checked first | 16,864 per second | +| the store file for them | 94 MB | +| one event by id (`req --local -i`) | 3.25 ms | +| one profile (`-a -k 0 -l 1`) | 3.30 ms | +| one author's latest 50 notes | 3.47 ms | +| the latest 500 notes (`-k 1 -l 500`) | 4.94 ms | +| every note, 98,000 of them | 284 ms | + +The query times are the whole command, starting deed included, so a lookup costs about a millisecond more than `deed version` does. Events are written to the store in batches, in one transaction each, and a batch is stored before any of it is printed. + +## Relays, over loopback + +| | | +| --- | --- | +| `req` for 500 events | 44.5 ms, of which the test relay starting a Python process is about 20 | +| `publish` 1,000 events, waiting for each one's OK | 6,884 per second | + +These leave the network out, so they measure deed and not the distance to a relay. Against a real relay, the round trip to it dominates. diff --git a/README.md b/README.md index e03488f..7a33551 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ macOS and Linux, Intel and ARM. It works out which build this machine wants, che The script is short and worth reading before you pipe anything into bash. If you would rather do it yourself: ```sh -VERSION=0.3.0 +VERSION=0.3.1 PLATFORM=macos-aarch64 # or macos-x86_64, linux-x86_64, linux-aarch64 BASE=https://github.com/zig-nostr/deed/releases/download/v$VERSION @@ -105,6 +105,10 @@ Scripts branch on these, so they are part of the interface and not free to drift | `2` | the command was not understood: unknown verb, unknown flag, missing argument. Nothing was attempted | | `141` | the reader on the other end of the pipe went away, as in `deed decode … \| head -1` | +## How fast it is + +A one-shot command runs in about 2.3 ms and under 2 MB of memory. deed signs about 22,000 events a second and verifies about 33,000, stores 100,000 events from a relay at about 17,000 a second, and answers a lookup from that store in about 3 ms, start to finish. The binaries are 2.3 to 2.7 MB. [BENCHMARKS.md](BENCHMARKS.md) has the full set and how to reproduce every number. + ## Build ```sh diff --git a/bench/relay.py b/bench/relay.py new file mode 100644 index 0000000..fa21869 --- /dev/null +++ b/bench/relay.py @@ -0,0 +1,27 @@ +"""A relay for benchmarking deed, on loopback only. + +websocat runs one of these per connection and hands it each websocket message +as a line. On REQ it sends every event in the corpus, then EOSE; on EVENT it +answers OK true. It ignores the filter, which is why the benchmark asks for +every kind the corpus holds. +""" +import json +import sys + +corpus = sys.argv[1] +out = sys.stdout +for line in sys.stdin: + try: + msg = json.loads(line) + except ValueError: + continue + if msg[0] == "REQ": + sub = json.dumps(msg[1]) + with open(corpus) as f: + for ev in f: + out.write('["EVENT",' + sub + "," + ev.rstrip("\n") + "]\n") + out.write(json.dumps(["EOSE", msg[1]]) + "\n") + out.flush() + elif msg[0] == "EVENT": + out.write(json.dumps(["OK", msg[1]["id"], True, ""]) + "\n") + out.flush() diff --git a/bench/run.py b/bench/run.py new file mode 100644 index 0000000..82c12f9 --- /dev/null +++ b/bench/run.py @@ -0,0 +1,149 @@ +"""Benchmarks for deed: startup, memory, bulk throughput, the local store, +and relays over loopback. + + python3 bench/run.py [path/to/deed] + +Needs python3 and websocat. Everything runs on this machine: the relay is +bench/relay.py on 127.0.0.1, and nothing is sent anywhere else. Each timing is +the best of several runs, because on a shared machine noise only ever adds. +""" +import json +import os +import shutil +import statistics +import subprocess +import sys +import tempfile +import time + +DEED = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "zig-out/bin/deed") +HERE = os.path.dirname(os.path.abspath(__file__)) +AUTHORS = 1000 +NOTES_PER_AUTHOR = 98 # plus a profile and a follow list: 100,000 events in all +PORTS = (47460, 47461) + + +def run(args, data=None): + r = subprocess.run([DEED] + args, input=data, capture_output=True) + if r.returncode != 0: + sys.exit(f"deed {' '.join(args[:3])} failed: {r.stderr.decode()[:300]}") + return r.stdout + + +def timed(argv, data=None, runs=5): + """Best and median wall time in milliseconds.""" + ts = [] + for _ in range(runs): + t = time.perf_counter() + r = subprocess.run(argv, input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + ts.append((time.perf_counter() - t) * 1000) + if r.returncode != 0: + sys.exit(f"{' '.join(argv[:3])} exited {r.returncode}") + return min(ts), statistics.median(ts) + + +def peak_mb(args, data=None): + """Peak resident memory, from /usr/bin/time.""" + flag = "-l" if sys.platform == "darwin" else "-v" + r = subprocess.run(["/usr/bin/time", flag, DEED] + args, input=data, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + for line in r.stderr.decode().splitlines(): + if "maximum resident set size" in line.lower(): + n = int(line.split()[0] if sys.platform == "darwin" else line.split()[-1]) + return n / 1048576 if sys.platform == "darwin" else n / 1024 + return float("nan") + + +def row(label, value): + print(f"| {label} | {value} |") + + +def main(): + work = tempfile.mkdtemp(prefix="deed-bench-") + relays = [] + try: + print(f"deed: {DEED} ({run(['version']).decode().strip()}), {os.path.getsize(DEED):,} bytes\n") + print("| | |\n| --- | --- |") + + key = run(["key", "generate"]).decode().strip() + npub = run(["key", "public", key]).decode().strip() + one = run(["event", "-c", "hello", "--sec", key]).decode().strip() + floor, _ = timed(["/usr/bin/true"], runs=200) + row("starting any process at all (`/usr/bin/true`)", f"{floor:.2f} ms") + for label, args in [ + ("`deed version`", ["version"]), + ("`deed key generate`", ["key", "generate"]), + ("`deed decode `", ["decode", npub]), + ("`deed event` (sign one)", ["event", "-c", "hello", "--sec", key]), + ("`deed verify` (one)", ["verify", one]), + ]: + best, _ = timed([DEED] + args, runs=200) + row(label, f"{best:.2f} ms") + row("peak memory, `deed version`", f"{peak_mb(['version']):.1f} MB") + + n = 10000 + drafts = "".join(json.dumps({"kind": 1, "content": f"note {i}, a few words of ordinary length"}) + "\n" for i in range(n)).encode() + signed = run(["event", "-", "--sec", key], drafts) + npubs = ((npub + "\n") * n).encode() + for label, args, data in [("sign", ["event", "-", "--sec", key], drafts), ("verify", ["verify"], signed), ("decode", ["decode"], npubs)]: + best, _ = timed([DEED] + args, data) + row(f"{label} 10,000 through stdin", f"{n / best * 1000:,.0f} per second") + row("peak memory, verifying 10,000", f"{peak_mb(['verify'], signed):.1f} MB") + + corpus = os.path.join(work, "corpus.jsonl") + with open(corpus, "wb") as f: + for a in range(AUTHORS): + k = run(["key", "generate"]).decode().strip() + d = [{"kind": 0, "content": json.dumps({"name": f"author {a}"}), "created_at": 1700000000 + a}, + {"kind": 3, "content": "", "created_at": 1700000001 + a}] + d += [{"kind": 1, "content": f"note {i} by author {a}, a few words of ordinary length", "created_at": 1700000000 + a * 100 + i} for i in range(NOTES_PER_AUTHOR)] + f.write(run(["event", "-", "--sec", k], "".join(json.dumps(x) + "\n" for x in d).encode())) + total = AUTHORS * (NOTES_PER_AUTHOR + 2) + with open(corpus) as f: + lines = f.readlines() + author = json.loads(lines[AUTHORS * 50])["pubkey"] + event_id = json.loads(lines[total // 2])["id"] + with open(os.path.join(work, "small.jsonl"), "w") as f: + f.writelines([l for l in lines if '"kind":1,' in l][:500]) + + websocat = shutil.which("websocat") or sys.exit("websocat is needed for the relay benchmarks") + for port, name in zip(PORTS, ("corpus.jsonl", "small.jsonl")): + relays.append(subprocess.Popen([websocat, "-t", f"ws-l:127.0.0.1:{port}", f"sh-c:exec python3 {HERE}/relay.py {work}/{name}"], stderr=subprocess.DEVNULL)) + time.sleep(1) + big, small = (f"ws://127.0.0.1:{p}" for p in PORTS) + + store = os.path.join(work, "store") + ingest = ["req", "--store", store, "-k", "0", "-k", "1", "-k", "3", "-l", str(total), "--timeout", "600000", big] + best = float("inf") + for _ in range(3): + for p in (store, store + "-lock"): + if os.path.exists(p): + os.remove(p) + t = time.perf_counter() + run(ingest) + best = min(best, time.perf_counter() - t) + row(f"store {total:,} events from a loopback relay, each checked", f"{total / best:,.0f} per second") + row("store file for them", f"{os.path.getsize(store) / 1048576:.0f} MB") + local = ["req", "--store", store, "--local"] + for label, args in [ + ("`--local`: one event by id", ["-i", event_id]), + ("`--local`: one profile", ["-a", author, "-k", "0", "-l", "1"]), + ("`--local`: one author's latest 50 notes", ["-a", author, "-k", "1", "-l", "50"]), + ("`--local`: latest 500 notes", ["-k", "1", "-l", "500"]), + ]: + best, _ = timed([DEED] + local + args, runs=30) + row(label, f"{best:.2f} ms") + best, _ = timed([DEED] + local + ["-k", "1", "-l", str(total)]) + row(f"`--local`: every note, {AUTHORS * NOTES_PER_AUTHOR:,}", f"{best:.0f} ms") + + best, _ = timed([DEED, "req", "-k", "1", "-l", "500", small], runs=20) + row("`req` 500 events from a loopback relay", f"{best:.1f} ms, of which the test relay starting Python is about 20") + pub = b"".join(signed.splitlines(keepends=True)[:1000]) + best, _ = timed([DEED, "publish", big], pub, runs=3) + row("`publish` 1,000 events to a loopback relay, each OK awaited", f"{1000 / best * 1000:,.0f} per second") + finally: + for r in relays: + r.kill() + shutil.rmtree(work, ignore_errors=True) + + +main() From c00d006516a2a348d8736a952fb6af4438b5bdfa Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Wed, 23 Sep 2026 17:31:52 +0300 Subject: [PATCH 3/3] chore(release): 0.3.1 Faster store writes, a faster allocator, stripped release binaries, and benchmarks. --- .github/RELEASE_NOTES.md | 12 ++++++++++++ build.zig.zon | 2 +- src/main.zig | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index cb02aa6..d72b941 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -2,6 +2,18 @@ Every artifact below is published with a `.sha256` beside it, so the download can be checked against a digest that was written by the same job that built it. +### What's new in v0.3.1 + +Faster, smaller, and measured. + +**Storing events is about seven times faster.** Events kept with `--store` are written in batches, one transaction each, instead of one transaction per event. Storing 100,000 events from a relay went from about 2,300 a second to about 16,800, each signature still checked, and a batch is still stored before any of it is printed. + +**Everything that allocates is faster.** deed now uses a general-purpose allocator instead of mapping pages for every allocation. Decoding a stream of npubs is about eight times faster, and verifying about a fifth faster. + +**The Linux downloads are less than half the size.** Release binaries are built without debug information. The Linux ones go from 12 MB to under 3 MB, and every download is now under 2 MB. + +**Benchmarks.** [BENCHMARKS.md](https://github.com/zig-nostr/deed/blob/main/BENCHMARKS.md) has the numbers, and `python3 bench/run.py` reproduces them on your machine. + ### What's new in v0.3.0 `publish` says what it published. diff --git a/build.zig.zon b/build.zig.zon index b58da04..5dfd0cc 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .deed, - .version = "0.3.0", + .version = "0.3.1", .fingerprint = 0x89498c2094a1a4a3, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/src/main.zig b/src/main.zig index fa30f07..11375a3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,7 +15,7 @@ const cmd_publish = @import("cmd_publish.zig"); const cmd_req = @import("cmd_req.zig"); const cmd_verify = @import("cmd_verify.zig"); -pub const version = "0.3.0"; +pub const version = "0.3.1"; const usage = \\deed: the nostr command line