Skip to content

Commit e81c184

Browse files
justrachclaude
andcommitted
fix: replace spin-loops with futex-based sleep to eliminate idle CPU burn
Worker threads and index workers were busy-waiting (spinLoopHint + yield) when no work was available, causing ~99% CPU at idle. Now they sleep on futex and are woken on demand. Seqlock and ART spin-locks yield after 16 spins to reduce contention burn. flushIndex uses exponential backoff. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 618a9c5 commit e81c184

4 files changed

Lines changed: 61 additions & 10 deletions

File tree

src/art.zig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,17 @@ const NodeHeader = struct {
4848
}
4949

5050
fn writeLock(self: *NodeHeader) void {
51+
var spins: u32 = 0;
5152
while (true) {
5253
const v = self.version.load(.acquire);
5354
if (v & 1 != 0) {
54-
std.atomic.spinLoopHint();
55+
spins += 1;
56+
if (spins > 16) {
57+
std.Thread.yield() catch {};
58+
spins = 0;
59+
} else {
60+
std.atomic.spinLoopHint();
61+
}
5562
continue;
5663
}
5764
if (self.version.cmpxchgWeak(v, v + 1, .acq_rel, .acquire)) |_| {

src/collection.zig

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ pub const Collection = struct {
193193
index_thread: ?std.Thread,
194194
index_thread2: ?std.Thread,
195195
index_stop: std.atomic.Value(bool),
196+
/// Futex signal — index workers sleep on this when queues are empty.
197+
index_wake: std.atomic.Value(u32),
196198
queue_toggle: std.atomic.Value(u32),
197199
indexing_count: std.atomic.Value(u32),
198200

@@ -248,6 +250,7 @@ pub const Collection = struct {
248250
col.index_queue = IndexQueue.init(alloc);
249251
col.index_queue2 = IndexQueue.init(alloc);
250252
col.index_stop = std.atomic.Value(bool).init(false);
253+
col.index_wake = std.atomic.Value(u32).init(0);
251254
col.queue_toggle = std.atomic.Value(u32).init(0);
252255
col.indexing_count = std.atomic.Value(u32).init(0);
253256
col.vectors = null;
@@ -271,6 +274,8 @@ pub const Collection = struct {
271274
pub fn close(self: *Collection) void {
272275
// Signal background indexers to stop, then join both threads.
273276
self.index_stop.store(true, .release);
277+
// Wake sleeping index workers so they observe the stop flag.
278+
std.Thread.Futex.wake(&self.index_wake, std.math.maxInt(u32));
274279
if (self.index_thread) |t| t.join();
275280
if (self.index_thread2) |t| t.join();
276281
// Drain any leftover entries from both queues (free owned slices).
@@ -322,9 +327,14 @@ pub const Collection = struct {
322327
/// Block until the background indexers have drained all pending items
323328
/// and finished processing the current batch.
324329
pub fn flushIndex(self: *Collection) void {
325-
var waited: u32 = 0;
326-
while ((self.index_queue.len() > 0 or self.index_queue2.len() > 0 or self.indexing_count.load(.acquire) > 0) and waited < 300_000) : (waited += 1) {
327-
std.Thread.sleep(100_000); // 100µs
330+
var sleep_ns: u64 = 100_000; // start at 100µs
331+
var total_ns: u64 = 0;
332+
const max_ns: u64 = 30_000_000_000; // 30s total cap
333+
while ((self.index_queue.len() > 0 or self.index_queue2.len() > 0 or self.indexing_count.load(.acquire) > 0) and total_ns < max_ns) {
334+
std.Thread.sleep(sleep_ns);
335+
total_ns += sleep_ns;
336+
// Exponential backoff: 100µs → 200µs → ... → 10ms cap.
337+
sleep_ns = @min(sleep_ns * 2, 10_000_000);
328338
}
329339
}
330340

@@ -425,6 +435,9 @@ pub const Collection = struct {
425435
}
426436
std.Thread.yield() catch {};
427437
}
438+
// Wake sleeping index worker to process the new entry.
439+
_ = self.index_wake.fetchAdd(1, .release);
440+
std.Thread.Futex.wake(&self.index_wake, 1);
428441
}
429442
}
430443
}
@@ -1434,7 +1447,10 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void {
14341447
}
14351448
if (n == 0) {
14361449
_ = col.indexing_count.fetchSub(1, .release);
1437-
std.Thread.yield() catch {};
1450+
// Sleep on futex instead of spinning — woken by index push path.
1451+
const cur = col.index_wake.load(.acquire);
1452+
if (!col.index_stop.load(.acquire))
1453+
std.Thread.Futex.timedWait(&col.index_wake, cur, 50_000_000) catch {};
14381454
continue;
14391455
}
14401456
// Batch trigram indexing (single lock acquisition for all docs).

src/io_engine.zig

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,9 @@ pub const EventLoop = struct {
456456
work_queue: *MpscRing(WorkItem, 4096),
457457
workers: []std.Thread,
458458
worker_count: usize,
459+
/// Futex-based wake signal — workers sleep on this instead of spinning.
460+
/// Incremented by the I/O thread after pushing work; workers wait on it.
461+
wake_signal: std.atomic.Value(u32),
459462

460463
pub fn init(alloc: Allocator, max_conns: usize) !EventLoop {
461464
const wq = try alloc.create(MpscRing(WorkItem, 4096));
@@ -473,13 +476,16 @@ pub const EventLoop = struct {
473476
.work_queue = wq,
474477
.workers = try alloc.alloc(std.Thread, n_workers),
475478
.worker_count = n_workers,
479+
.wake_signal = std.atomic.Value(u32).init(0),
476480
};
477481
}
478482

479483
pub fn deinit(self: *EventLoop) void {
480484
if (self.running.load(.acquire)) {
481485
self.running.store(false, .release);
482486
}
487+
// Wake all workers so they can observe running=false and exit.
488+
std.Thread.Futex.wake(&self.wake_signal, @intCast(self.worker_count));
483489
if (self.listen_fd >= 0) {
484490
posix.close(self.listen_fd);
485491
}
@@ -541,6 +547,8 @@ pub const EventLoop = struct {
541547
/// Stop the event loop gracefully.
542548
pub fn stop(self: *EventLoop) void {
543549
self.running.store(false, .release);
550+
// Wake all sleeping workers so they see running=false.
551+
std.Thread.Futex.wake(&self.wake_signal, @intCast(self.worker_count));
544552
}
545553

546554
// ── Internal completion handlers ──
@@ -608,6 +616,10 @@ pub const EventLoop = struct {
608616
// Queue full — close connection to shed load.
609617
conn.state = .closing;
610618
self.engine.submitClose(conn.fd, comp.user_data) catch {};
619+
} else {
620+
// Wake one sleeping worker to process this item.
621+
_ = self.wake_signal.fetchAdd(1, .release);
622+
std.Thread.Futex.wake(&self.wake_signal, 1);
611623
}
612624
}
613625

@@ -650,9 +662,11 @@ pub const EventLoop = struct {
650662
fn workerThread(self: *EventLoop, handler: *const fn (request: []const u8, response: []u8) usize) void {
651663
while (self.running.load(.acquire)) {
652664
const item = self.work_queue.pop() orelse {
653-
// No work — brief spin-then-yield.
654-
std.atomic.spinLoopHint();
655-
std.Thread.yield() catch {};
665+
// No work — sleep on futex instead of spinning.
666+
const cur = self.wake_signal.load(.acquire);
667+
// Re-check running before sleeping (avoid missed wake on shutdown).
668+
if (!self.running.load(.acquire)) break;
669+
std.Thread.Futex.timedWait(&self.wake_signal, cur, 50_000_000) catch {};
656670
continue;
657671
};
658672

src/storage/seqlock.zig

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ pub const Seqlock = struct {
2727
/// Begin a read. Returns the current sequence number.
2828
/// Spins until no write is in progress (seq is even).
2929
pub inline fn readBegin(self: *const Seqlock) u64 {
30+
var spins: u32 = 0;
3031
while (true) {
3132
const s = self.seq.load(.acquire);
3233
if (s & 1 == 0) return s;
33-
std.atomic.spinLoopHint();
34+
spins += 1;
35+
if (spins > 16) {
36+
std.Thread.yield() catch {};
37+
spins = 0;
38+
} else {
39+
std.atomic.spinLoopHint();
40+
}
3441
}
3542
}
3643

@@ -45,14 +52,21 @@ pub const Seqlock = struct {
4552
/// Acquire the write lock (spins until no other writer is active).
4653
/// After this call seq is ODD — readers will spin.
4754
pub fn writeLock(self: *Seqlock) void {
55+
var spins: u32 = 0;
4856
while (true) {
4957
const s = self.seq.load(.acquire);
5058
if (s & 1 == 0) {
5159
// Try CAS even → odd
5260
if (self.seq.cmpxchgStrong(s, s + 1, .acq_rel, .monotonic) == null)
5361
return;
5462
}
55-
std.atomic.spinLoopHint();
63+
spins += 1;
64+
if (spins > 16) {
65+
std.Thread.yield() catch {};
66+
spins = 0;
67+
} else {
68+
std.atomic.spinLoopHint();
69+
}
5670
}
5771
}
5872

0 commit comments

Comments
 (0)