From 02b3289c6eb202869300bdf09aa3e7c52ffedfb2 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 8 May 2026 18:12:47 +0300 Subject: [PATCH 1/6] Update to Zig 0.16, refresh deps, refresh README numbers --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- .mise.toml | 4 +- CHANGELOG.md | 6 + README.md | 44 ++-- benchmarks/Dockerfile | 2 +- build.zig | 25 +-- build.zig.zon | 10 +- ext/carbon_fiber_native/bindings.zig | 69 +++--- ext/carbon_fiber_native/io.zig | 8 +- ext/carbon_fiber_native/main.zig | 15 +- ext/carbon_fiber_native/selector.zig | 286 ++++++++++++++---------- ext/carbon_fiber_native/support.zig | 47 +++- ext/carbon_fiber_native/timer_queue.zig | 31 +-- lib/carbon_fiber/scheduler.rb | 10 +- 15 files changed, 308 insertions(+), 253 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe5a801..f253777 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: - name: Install Zig run: | ZIG_ARCH=$(uname -m | sed 's/arm64/aarch64/') - ZIG_VERSION="0.15.2" + ZIG_VERSION="0.16.0" curl -sSfL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-macos-${ZIG_VERSION}.tar.xz" \ | tar xJ -C /tmp echo "/tmp/zig-${ZIG_ARCH}-macos-${ZIG_VERSION}" >> $GITHUB_PATH diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db65181..fd4b730 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: - name: Install Zig if: ${{ !matrix.fallback }} run: | - ZIG_VERSION="0.15.2" + ZIG_VERSION="0.16.0" ZIG_ARCH=$(uname -m | sed 's/arm64/aarch64/') # Zig uses "macos" in download slugs, not "darwin" as uname reports. ZIG_OS=$(uname -s | tr '[:upper:]' '[:lower:]' | sed 's/darwin/macos/') diff --git a/.mise.toml b/.mise.toml index f437a27..5073f24 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,3 +1,3 @@ [tools] -ruby = ["4.0.2", "3.4.8"] -zig = "0.15.2" +ruby = ["4.0.3", "3.4.9"] +zig = "0.16.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c2d0e..2a8bef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +- Update to Zig 0.16.0; update libxev and zig.rb to the latest versions. +- Performance improvements. README updated with latest numbers. +- Improve benchmarking: default to 3 measured runs plus 1 warmup run. Now reporting `±CV%` and the delta column tags rows `(noisy)` when the change is within jitter. Add the ability to benchmark released vs. unreleased carbon_fiber. + ## [0.1.2] - 2026-04-29 - Fix the `x86_64-linux` precompiled gem (erroneously shipped an aarch64 diff --git a/README.md b/README.md index 0a4e3a0..2e7bbb2 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,18 @@ end ## Performance -AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Ubuntu 24.04 LTS, kernel 6.17, Ruby 4.0.2 + YJIT, io_uring. 5-run median. +AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Amazon Linux 2023, kernel 6.18.20, Ruby 4.0.2 + YJIT, io_uring. 5-run median. Some benchmarks: | Workload | Carbon Fiber | Async | Itsi | Carbon Fiber vs. Async | |---|---|---|---|---| -| `http_server` | **48.175k req/s** | 37.409k req/s | 29.708k req/s | +29% | -| `http_client_api` | **15.528k req/s** | 13.373k req/s | timeout | +16% | -| `http_client_download` | **6.747k dl/s** | 5.920k dl/s | timeout | +14% | -| `tcp_echo` | **50.392k ops/s** | 38.907k ops/s | 30.660k ops/s | +29% | -| `cascading_timeout` | **4.659k ops/s** | 4.488k ops/s | error | +4% | -| `connection_pool` | **4.989k co/s** | 4.912k co/s | 4.968k co/s | +2% | +| `http_server` | **49.380k req/s** | 30.823k req/s | 30.864k req/s | +60% | +| `http_client_api` | **19.500k req/s** | 16.721k req/s | timeout | +17% | +| `http_client_download` | **8.426k dl/s** | 7.062k dl/s | timeout | +19% | +| `tcp_echo` | **52.973k ops/s** | 32.330k ops/s | 32.046k ops/s | +64% | +| `cascading_timeout` | **4.668k ops/s** | 4.414k ops/s | error | +6% | +| `connection_pool` | **4.967k co/s** | 4.612k co/s | 4.954k co/s | +8% | Wins on most workloads against Async, Itsi, fiber_scheduler, io-event, and libev. [See detailed benchmarks →](#benchmarks) @@ -256,7 +256,7 @@ If the native extension can't be loaded (on Windows, for example), a pure-Ruby f ## Benchmarks -AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Ubuntu 24.04 LTS, kernel 6.17, Ruby 4.0.2 + YJIT, io_uring. 5-run median. +AWS EC2 c7a.2xlarge, 8 dedicated vCPUs, Amazon Linux 2023, kernel 6.18.20, Ruby 4.0.2 + YJIT, io_uring. 5-run median. ### Ruby Fiber Schedulers (leading ones): Carbon Fiber vs. Async vs. Itsi @@ -264,14 +264,14 @@ Measuring pure Ruby Fiber Scheduler performance (`Fiber.set_scheduler`). | Workload | Unit | Carbon Fiber | Async | Itsi | Carbon Fiber vs. Async | |---|---|---|---|---|---| -| `http_client_api` | req/s | **15,528** | 13,373 | timeout | +16% | -| `http_client_download` | dl/s | **6,747** | 5,920 | timeout | +14% | -| `http_server` | req/s | **48,175** | 37,409 | 29,708 | +29% | -| `tcp_echo` | ops/s | **50,392** | 38,907 | 30,660 | +29% | -| `connection_pool` | co/s | **4,989** | 4,912 | 4,968 | +2% | -| `fan_out_gather` | cyc/s | 2,024 | 2,046 | **2,104** | −1% | -| `db_query_mix` | qry/s | 1,660 | 1,652 | **1,662** | +0.5% | -| `cascading_timeout` | ops/s | **4,659** | 4,488 | error | +4% | +| `http_client_api` | req/s | **19,500** | 16,721 | timeout | +17% | +| `http_client_download` | dl/s | **8,426** | 7,062 | timeout | +19% | +| `http_server` | req/s | **49,380** | 30,823 | 30,864 | +60% | +| `tcp_echo` | ops/s | **52,973** | 32,330 | 32,046 | +64% | +| `connection_pool` | co/s | **4,967** | 4,612 | 4,954 | +8% | +| `fan_out_gather` | cyc/s | 2,022 | 1,923 | **2,094** | +5% | +| `db_query_mix` | qry/s | 1,660 | 1,623 | **1,662** | +2% | +| `cascading_timeout` | ops/s | **4,668** | 4,414 | error | +6% | Enabling YJIT turned out to be very beneficial for Async as well—numbers here are with `--yjit` on both sides. @@ -281,12 +281,12 @@ Swapped the io-event selector for Carbon Fiber's native backend. Same Async code | Workload | Unit | Stock Async | Carbon Fiber | Delta | |---|---|---|---|---| -| `http_client_api` | req/s | 13,375 | **14,331** | +7.1% | -| `http_client_download` | dl/s | 3,893 | **3,956** | +1.6% | -| `task_churn` | task/s | **87,883** | 85,027 | −3.3% | -| `condition_signal` | sig/s | 337,282 | **361,089** | +7.1% | -| `cascading_timeout` | ops/s | 4,497 | **4,511** | +0.3% | -| `tcp_throughput` | ops/s | 42,292 | **51,930** | +22.8% | +| `http_client_api` | req/s | 17,119 | **17,323** | +1.2% | +| `http_client_download` | dl/s | 2,263 | **2,474** | +9.3% | +| `task_churn` | task/s | 119,477 | **120,985** | +1.3% | +| `condition_signal` | sig/s | 44,887 | **46,921** | +4.5% | +| `cascading_timeout` | ops/s | 4,414 | **4,472** | +1.3% | +| `tcp_throughput` | ops/s | 32,606 | **48,214** | +47.9% | ### Examples of how to run benchmarks diff --git a/benchmarks/Dockerfile b/benchmarks/Dockerfile index 3ed0f9c..830ddc8 100644 --- a/benchmarks/Dockerfile +++ b/benchmarks/Dockerfile @@ -1,6 +1,6 @@ FROM ruby:4.0.2-bookworm -ARG ZIG_VERSION=0.15.2 +ARG ZIG_VERSION=0.16.0 ENV PATH=/opt/zig:$PATH diff --git a/build.zig b/build.zig index e71f262..b71eb25 100644 --- a/build.zig +++ b/build.zig @@ -13,25 +13,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); - // zig_rb's build.zig calls `zig_tests.linkSystemLibrary("ruby")` on a - // Compile step whose root_module is the same module exported to - // consumers; via the deprecated alias, that mutates the shared module's - // link_objects with a "ruby" system_lib. Inheriting it would bake an - // absolute libruby path into the macOS bundle and pull in whatever - // libruby Linux's ld happens to resolve (often the runner's system - // Ruby, breaking cross-version loads). Strip it here; Ruby's C-API - // symbols are resolved at dlopen against the host ruby process, like - // every other distributable Ruby native extension. const rb_module = zig_rb_dep.module("zig_rb"); - var i: usize = 0; - while (i < rb_module.link_objects.items.len) { - const lo = rb_module.link_objects.items[i]; - if (lo == .system_lib and std.mem.eql(u8, lo.system_lib.name, "ruby")) { - _ = rb_module.link_objects.orderedRemove(i); - } else { - i += 1; - } - } const fibers_module = b.createModule(.{ .root_source_file = b.path("ext/carbon_fiber_native/main.zig"), @@ -64,11 +46,8 @@ pub fn build(b: *std.Build) void { const is_macos = target.result.os.tag == .macos; if (is_macos) fibers_ext.linker_allow_shlib_undefined = true; - // Enable full LTO for release builds to allow cross-module inlining between - // the event loop, I/O helpers, and Ruby binding layers. - // macOS excluded: Zig 0.15's linker can't resolve Xcode 26.4 TBD entries, - // so we use DEVELOPER_DIR=/dev/null which bypasses the system SDK but also - // prevents LLD (required for LTO) from linking against libSystem. + // Enable full LTO for release builds to allow cross-module inlining + // between the event loop, I/O helpers, and Ruby binding layers. if (optimize != .Debug and !is_macos) fibers_ext.lto = .full; const allocator = b.allocator; diff --git a/build.zig.zon b/build.zig.zon index 2f8f485..675fca5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,16 +1,16 @@ .{ .name = .carbon_fiber, .version = "0.1.2", - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .fingerprint = 0xa77e58bde51c90b2, .dependencies = .{ .zig_rb = .{ - .url = "https://github.com/furunkel/zig.rb/archive/00f0e2aabcb90664d8c29683d266c4a0030e36cc.tar.gz", - .hash = "zig_rb-0.0.0-dYaqXfhMAQDmO0OQGx0oWgU9O6QkQCJGpKef8GstBXuM", + .url = "https://github.com/furunkel/zig.rb/archive/239820962c633dfd97d298a2677518db32f1980e.tar.gz", + .hash = "zig_rb-0.0.0-dYaqXUQlDQB-CO_JCQsjcSFgYhNdTFDHhMMEsurw6v3v", }, .libxev = .{ - .url = "https://github.com/mitchellh/libxev/archive/d398cba9.tar.gz", - .hash = "libxev-0.0.0-86vtc689EwAjQWysidPm9NKkAwwUNByiE3kZTg_xu0Hp", + .url = "https://github.com/mitchellh/libxev/archive/9ce8e8e6ff89e583258a7f8e7adeeeaeae8611bf.tar.gz", + .hash = "libxev-0.0.0-86vtcwIRFADbH4hk-EjROXxlrKIRPQdA41XiTSytYO-F", }, }, .paths = .{ diff --git a/ext/carbon_fiber_native/bindings.zig b/ext/carbon_fiber_native/bindings.zig index c703071..cfa19c9 100644 --- a/ext/carbon_fiber_native/bindings.zig +++ b/ext/carbon_fiber_native/bindings.zig @@ -11,6 +11,18 @@ const Value = rb.Value; const crb = rb.crb; const Selector = @import("selector.zig").Selector; +// crb.zig auto-translates Ruby's K&R-style `(*VALUE)()` parameter as +// `?*const fn (...) callconv(.c) VALUE`. Zig 0.16 won't coerce a fixed-arity +// function pointer into that slot. Re-declare the same C symbol with a +// `*const anyopaque` slot, which a typed function pointer assigns into +// directly. The linker resolves both declarations to the same C function. +extern fn rb_define_method( + klass: crb.VALUE, + mid: [*c]const u8, + func: *const anyopaque, + arity: c_int, +) void; + pub fn register(native_raw: crb.VALUE) void { const selector_class = crb.rb_define_class_under(native_raw, "Selector", crb.rb_cObject); crb.rb_define_alloc_func(selector_class, Selector.RubyType.alloc_func); @@ -40,6 +52,7 @@ pub fn register(native_raw: crb.VALUE) void { define(selector_class, "process_wait", selectorProcessWaitWrapper, 3); define(selector_class, "poll_readable_now", selectorPollReadableNowWrapper, 1); define(selector_class, "cancel_block_timer", selectorCancelBlockTimerWrapper, 1); + define(selector_class, "kernel_sleep", selectorKernelSleepWrapper, 1); // Aliases for subclass use: Ruby overrides of push/io_wait/etc. can call // these to reach the native implementation without conflicting with their @@ -53,7 +66,7 @@ pub fn register(native_raw: crb.VALUE) void { } fn define(class_value: crb.VALUE, name: [*:0]const u8, comptime func: anytype, argc: c_int) void { - crb.rb_define_method(class_value, name, @as(?*const fn (...) callconv(.c) crb.VALUE, @ptrCast(&func)), argc); + rb_define_method(class_value, name, &func, argc); } fn unwrap(rb_self: crb.VALUE) *Selector { @@ -61,101 +74,105 @@ fn unwrap(rb_self: crb.VALUE) *Selector { } fn selectorInitializeWrapper(rb_self: crb.VALUE, loop_fiber_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.initialize(unwrap(rb_self), Value.fromRaw(loop_fiber_raw)).toRaw(); + return Selector.InstanceMethods.initialize(unwrap(rb_self), Value.fromRaw(loop_fiber_raw)).asRaw(); } fn selectorDestroyWrapper(rb_self: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.destroy(unwrap(rb_self)).toRaw(); + return Selector.InstanceMethods.destroy(unwrap(rb_self)).asRaw(); } fn selectorPendingWrapper(rb_self: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.pending(unwrap(rb_self)).toRaw(); + return Selector.InstanceMethods.pending(unwrap(rb_self)).asRaw(); } fn selectorPushWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.push(unwrap(rb_self), Value.fromRaw(fiber_raw)).toRaw(); + return Selector.InstanceMethods.push(unwrap(rb_self), Value.fromRaw(fiber_raw)).asRaw(); } fn selectorResumeWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, value_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.@"resume"(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(value_raw)).toRaw(); + return Selector.InstanceMethods.@"resume"(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(value_raw)).asRaw(); } fn selectorRaiseWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, exception_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.raise(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(exception_raw)).toRaw(); + return Selector.InstanceMethods.raise(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(exception_raw)).asRaw(); } fn selectorWakeupWrapper(rb_self: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.wakeup(unwrap(rb_self)).toRaw(); + return Selector.InstanceMethods.wakeup(unwrap(rb_self)).asRaw(); } fn selectorTransferWrapper(rb_self: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.transfer(unwrap(rb_self)).toRaw(); + return Selector.InstanceMethods.transfer(unwrap(rb_self)).asRaw(); } fn selectorYieldWrapper(rb_self: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.@"yield"(unwrap(rb_self)).toRaw(); + return Selector.InstanceMethods.@"yield"(unwrap(rb_self)).asRaw(); +} + +fn selectorKernelSleepWrapper(rb_self: crb.VALUE, duration_raw: crb.VALUE) callconv(.c) crb.VALUE { + return Selector.InstanceMethods.kernel_sleep(unwrap(rb_self), Value.fromRaw(duration_raw)).asRaw(); } fn selectorSelectWrapper(rb_self: crb.VALUE, timeout_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.select(unwrap(rb_self), Value.fromRaw(timeout_raw)).toRaw(); + return Selector.InstanceMethods.select(unwrap(rb_self), Value.fromRaw(timeout_raw)).asRaw(); } fn selectorBlockWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, timeout_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.block(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(timeout_raw)).toRaw(); + return Selector.InstanceMethods.block(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(timeout_raw)).asRaw(); } fn selectorUnblockWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.unblock(unwrap(rb_self), Value.fromRaw(fiber_raw)).toRaw(); + return Selector.InstanceMethods.unblock(unwrap(rb_self), Value.fromRaw(fiber_raw)).asRaw(); } fn selectorRaiseAfterWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, exception_raw: crb.VALUE, duration_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.raise_after(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(exception_raw), Value.fromRaw(duration_raw)).toRaw(); + return Selector.InstanceMethods.raise_after(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(exception_raw), Value.fromRaw(duration_raw)).asRaw(); } fn selectorCancelTimerWrapper(rb_self: crb.VALUE, token_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.cancel_timer(unwrap(rb_self), Value.fromRaw(token_raw)).toRaw(); + return Selector.InstanceMethods.cancel_timer(unwrap(rb_self), Value.fromRaw(token_raw)).asRaw(); } fn selectorIoWaitWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, fd_raw: crb.VALUE, events_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_wait(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(fd_raw), Value.fromRaw(events_raw)).toRaw(); + return Selector.InstanceMethods.io_wait(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(fd_raw), Value.fromRaw(events_raw)).asRaw(); } fn selectorIoWaitWithTimeoutWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, fd_raw: crb.VALUE, events_raw: crb.VALUE, timeout_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_wait_with_timeout(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(fd_raw), Value.fromRaw(events_raw), Value.fromRaw(timeout_raw)).toRaw(); + return Selector.InstanceMethods.io_wait_with_timeout(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(fd_raw), Value.fromRaw(events_raw), Value.fromRaw(timeout_raw)).asRaw(); } fn selectorIoWaitObjectWrapper(rb_self: crb.VALUE, io_raw: crb.VALUE, events_raw: crb.VALUE, timeout_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_wait_object(unwrap(rb_self), Value.fromRaw(io_raw), Value.fromRaw(events_raw), Value.fromRaw(timeout_raw)).toRaw(); + return Selector.InstanceMethods.io_wait_object(unwrap(rb_self), Value.fromRaw(io_raw), Value.fromRaw(events_raw), Value.fromRaw(timeout_raw)).asRaw(); } fn selectorIoCloseWrapper(rb_self: crb.VALUE, fd_raw: crb.VALUE, exception_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_close(unwrap(rb_self), Value.fromRaw(fd_raw), Value.fromRaw(exception_raw)).toRaw(); + return Selector.InstanceMethods.io_close(unwrap(rb_self), Value.fromRaw(fd_raw), Value.fromRaw(exception_raw)).asRaw(); } fn selectorIoReadWrapper(rb_self: crb.VALUE, fd_raw: crb.VALUE, buffer_raw: crb.VALUE, length_raw: crb.VALUE, offset_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_read(unwrap(rb_self), Value.fromRaw(fd_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).toRaw(); + return Selector.InstanceMethods.io_read(unwrap(rb_self), Value.fromRaw(fd_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).asRaw(); } fn selectorIoReadObjectWrapper(rb_self: crb.VALUE, io_raw: crb.VALUE, buffer_raw: crb.VALUE, length_raw: crb.VALUE, offset_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_read_object(unwrap(rb_self), Value.fromRaw(io_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).toRaw(); + return Selector.InstanceMethods.io_read_object(unwrap(rb_self), Value.fromRaw(io_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).asRaw(); } fn selectorIoWriteWrapper(rb_self: crb.VALUE, fd_raw: crb.VALUE, buffer_raw: crb.VALUE, length_raw: crb.VALUE, offset_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_write(unwrap(rb_self), Value.fromRaw(fd_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).toRaw(); + return Selector.InstanceMethods.io_write(unwrap(rb_self), Value.fromRaw(fd_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).asRaw(); } fn selectorIoWriteObjectWrapper(rb_self: crb.VALUE, io_raw: crb.VALUE, buffer_raw: crb.VALUE, length_raw: crb.VALUE, offset_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.io_write_object(unwrap(rb_self), Value.fromRaw(io_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).toRaw(); + return Selector.InstanceMethods.io_write_object(unwrap(rb_self), Value.fromRaw(io_raw), Value.fromRaw(buffer_raw), Value.fromRaw(length_raw), Value.fromRaw(offset_raw)).asRaw(); } fn selectorProcessWaitWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE, pid_raw: crb.VALUE, flags_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.process_wait(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(pid_raw), Value.fromRaw(flags_raw)).toRaw(); + return Selector.InstanceMethods.process_wait(unwrap(rb_self), Value.fromRaw(fiber_raw), Value.fromRaw(pid_raw), Value.fromRaw(flags_raw)).asRaw(); } fn selectorPollReadableNowWrapper(rb_self: crb.VALUE, fd_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.poll_readable_now(unwrap(rb_self), Value.fromRaw(fd_raw)).toRaw(); + return Selector.InstanceMethods.poll_readable_now(unwrap(rb_self), Value.fromRaw(fd_raw)).asRaw(); } fn selectorCancelBlockTimerWrapper(rb_self: crb.VALUE, fiber_raw: crb.VALUE) callconv(.c) crb.VALUE { - return Selector.InstanceMethods.cancel_block_timer(unwrap(rb_self), Value.fromRaw(fiber_raw)).toRaw(); + return Selector.InstanceMethods.cancel_block_timer(unwrap(rb_self), Value.fromRaw(fiber_raw)).asRaw(); } diff --git a/ext/carbon_fiber_native/io.zig b/ext/carbon_fiber_native/io.zig index 1e0722c..80900a8 100644 --- a/ext/carbon_fiber_native/io.zig +++ b/ext/carbon_fiber_native/io.zig @@ -10,7 +10,7 @@ const std = @import("std"); /// bytes read (≥0) or negated errno. /// Used by ioRead's fast path to avoid fiber lookup when data is /// already available. -pub fn recvOnce(fd: std.posix.fd_t, buf: []u8) isize { +pub inline fn recvOnce(fd: std.posix.fd_t, buf: []u8) isize { while (true) { const rc = std.c.recv(fd, @ptrCast(buf.ptr), buf.len, std.posix.MSG.DONTWAIT); if (rc >= 0) return @intCast(rc); @@ -22,7 +22,7 @@ pub fn recvOnce(fd: std.posix.fd_t, buf: []u8) isize { /// Non-blocking send on a pre-computed buffer slice. Returns /// bytes written (≥0) or negated errno. -pub fn sendOnce(fd: std.posix.fd_t, buf: []const u8) isize { +pub inline fn sendOnce(fd: std.posix.fd_t, buf: []const u8) isize { while (true) { const rc = std.c.send(fd, @ptrCast(buf.ptr), buf.len, std.posix.MSG.DONTWAIT); if (rc >= 0) return @intCast(rc); @@ -61,7 +61,7 @@ pub fn drainSend(fd: std.posix.fd_t, buf: [*]const u8, total_len: usize, initial /// Returns true if the negated errno indicates the operation would block. /// Checks both EAGAIN and EWOULDBLOCK (which are the same on Linux but /// are distinct constants on some other platforms). -pub fn wouldBlockErrno(errno_value: isize) bool { +pub inline fn wouldBlockErrno(errno_value: isize) bool { const again = @intFromEnum(std.posix.E.AGAIN); const would_block = if (@hasField(std.posix.E, "WOULDBLOCK")) @intFromEnum(@field(std.posix.E, "WOULDBLOCK")) @@ -70,7 +70,7 @@ pub fn wouldBlockErrno(errno_value: isize) bool { return errno_value == again or errno_value == would_block; } -pub fn isEnotsock(errno_value: isize) bool { +pub inline fn isEnotsock(errno_value: isize) bool { return errno_value == @intFromEnum(std.posix.E.NOTSOCK); } diff --git a/ext/carbon_fiber_native/main.zig b/ext/carbon_fiber_native/main.zig index 271917b..a7be195 100644 --- a/ext/carbon_fiber_native/main.zig +++ b/ext/carbon_fiber_native/main.zig @@ -6,16 +6,25 @@ const Value = rb.Value; const crb = rb.crb; const bindings = @import("bindings.zig"); +// See bindings.zig for the rationale on re-declaring this with an +// `*const anyopaque` slot. +extern fn rb_define_module_function( + module: crb.VALUE, + name: [*c]const u8, + func: *const anyopaque, + arity: c_int, +) void; + fn defineModuleFunction(module_value: crb.VALUE, name: [*:0]const u8, comptime func: anytype, argc: c_int) void { - crb.rb_define_module_function(module_value, name, @as(?*const fn (...) callconv(.c) crb.VALUE, @ptrCast(&func)), argc); + rb_define_module_function(module_value, name, &func, argc); } fn availableWrapper(_: crb.VALUE) callconv(.c) crb.VALUE { - return Value.from(true).toRaw(); + return Value.from(true).asRaw(); } fn backendWrapper(_: crb.VALUE) callconv(.c) crb.VALUE { - return Value.from("libxev").toRaw(); + return Value.from("libxev").asRaw(); } export fn Init_carbon_fiber_native() void { diff --git a/ext/carbon_fiber_native/selector.zig b/ext/carbon_fiber_native/selector.zig index b75729e..cb275f1 100644 --- a/ext/carbon_fiber_native/selector.zig +++ b/ext/carbon_fiber_native/selector.zig @@ -61,6 +61,26 @@ const SPIN_THRESHOLD: f64 = 0.003; // 1024+, but active fd counts rarely exceed ~200 on a single scheduler). const DESCRIPTOR_CACHE_SIZE: usize = 256; +// Replacement for std.Thread.Mutex, which Zig 0.16 relocated to std.Io.Mutex +// and made dependent on an std.Io context we do not have here. Cross-thread +// critical sections in this file are tiny (append a single ReadyEntry, drain +// a short list), and contention is the rare case since same-thread enqueue +// goes through the no-mutex path. A tryLock + Thread.yield loop over +// std.atomic.Mutex is enough; the kernel handles backoff. +const ThreadMutex = struct { + inner: std.atomic.Mutex = .unlocked, + + fn lock(self: *ThreadMutex) void { + while (!self.inner.tryLock()) { + std.Thread.yield() catch {}; + } + } + + fn unlock(self: *ThreadMutex) void { + self.inner.unlock(); + } +}; + const Direction = enum(u1) { read = 0, write = 1 }; const ReadyEntry = struct { @@ -90,7 +110,7 @@ const Descriptor = struct { in_map: bool = false, closed: bool = false, - fn pollFor(self: *Descriptor, comptime dir: Direction) *PollState { + inline fn pollFor(self: *Descriptor, comptime dir: Direction) *PollState { return &self.poll[@intFromEnum(dir)]; } @@ -144,17 +164,17 @@ pub const Selector = struct { timers: TimerQueue = undefined, - descriptors: std.AutoHashMapUnmanaged(std.posix.fd_t, *Descriptor) = .{}, - retired_descriptors: std.ArrayListUnmanaged(*Descriptor) = .{}, - process_waits: std.ArrayListUnmanaged(*ProcessWait) = .{}, - retired_process_waits: std.ArrayListUnmanaged(*ProcessWait) = .{}, + descriptors: std.AutoHashMapUnmanaged(std.posix.fd_t, *Descriptor) = .empty, + retired_descriptors: std.ArrayListUnmanaged(*Descriptor) = .empty, + process_waits: std.ArrayListUnmanaged(*ProcessWait) = .empty, + retired_process_waits: std.ArrayListUnmanaged(*ProcessWait) = .empty, active_waiters: usize = 0, - ready_entries: std.ArrayListUnmanaged(ReadyEntry) = .{}, + ready_entries: std.ArrayListUnmanaged(ReadyEntry) = .empty, ready_head: usize = 0, - cross_thread_mutex: std.Thread.Mutex = .{}, - cross_thread_entries: std.ArrayListUnmanaged(ReadyEntry) = .{}, + cross_thread_mutex: ThreadMutex = .{}, + cross_thread_entries: std.ArrayListUnmanaged(ReadyEntry) = .empty, cross_thread_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), // Tracks every fiber that is voluntarily parked via block() or an I/O wait @@ -171,7 +191,7 @@ pub const Selector = struct { // 2. raise() reads the timer token to cancel any pending block timeout // before enqueuing the exception — otherwise the longjmp bypasses // block()'s cleanup and the dangling timer keeps hasPending() true. - blocked_fibers: std.AutoHashMapUnmanaged(crb.VALUE, u64) = .{}, + blocked_fibers: std.AutoHashMapUnmanaged(crb.VALUE, u64) = .empty, // Per-fd consecutive EAGAIN miss counter for the recvOnce probe in ioRead. // Indexed by fd & 0xFF. When a probe misses PROBE_SKIP_THRESHOLD times in a @@ -208,7 +228,10 @@ pub const Selector = struct { .dmark = &selectorMark, .dfree = &selectorFree, .dsize = null, - .dcompact = &selectorCompact, + // No dcompact: markValue uses rb_gc_mark_maybe which pins + // the marked VALUEs in place, so compaction never moves + // anything we hold and we don't need a relocation pass. + .dcompact = null, .reserved = .{null}, }, .parent = null, @@ -233,7 +256,7 @@ pub const Selector = struct { /// Set up the event loop and ready queue. Called once per Selector. pub fn initialize(self: *Self, loop_fiber_val: Value) Value { if (!self.initialized) { - self.setup(std.heap.c_allocator, loop_fiber_val.toRaw()) catch + self.setup(std.heap.c_allocator, loop_fiber_val.asRaw()) catch Error.raiseRuntimeError("Failed to initialize CarbonFiber::Native::Selector"); } return Value.nil; @@ -255,7 +278,7 @@ pub const Selector = struct { /// Enqueue a fiber into the ready queue (thread-safe). pub fn push(self: *Self, fiber_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); if (support.rb_thread_current() == self.scheduler_thread) { // Same thread: direct enqueue, no locking needed self.enqueue(.resume_fiber, fiber, crb.Qnil) catch @@ -265,7 +288,7 @@ pub const Selector = struct { self.enqueueCrossThread(.{ .kind = .resume_fiber, .fiber = fiber, - .payload = Value.from(true).toRaw(), + .payload = Value.from(true).asRaw(), }) catch Error.raiseRuntimeError("Failed to enqueue fiber (cross-thread)"); if (self.blocked.load(.acquire)) { self.async_handle.notify() catch @@ -279,8 +302,8 @@ pub const Selector = struct { /// background threads via Scheduler#await_background_operation. pub fn @"resume"(self: *Self, fiber_val: Value, value_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); - const payload = value_val.toRaw(); + const fiber = fiber_val.asRaw(); + const payload = value_val.asRaw(); if (support.rb_thread_current() == self.scheduler_thread) { self.enqueue(.resume_fiber, fiber, payload) catch Error.raiseRuntimeError("Failed to enqueue resume"); @@ -302,7 +325,7 @@ pub const Selector = struct { /// Cancels any pending block timeout. pub fn raise(self: *Self, fiber_val: Value, exception_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); // Cancel any pending block() timeout for this fiber. If we don't do // this, the longjmp that delivers the exception bypasses the cancel // call in block(), leaving a dangling timer that @@ -315,7 +338,7 @@ pub const Selector = struct { token_ptr.* = 0; // clear so block()'s cleanup doesn't double-cancel } } - self.enqueue(.raise, fiber, exception_val.toRaw()) catch + self.enqueue(.raise, fiber, exception_val.asRaw()) catch Error.raiseRuntimeError("Failed to enqueue raise"); return fiber_val; } @@ -360,11 +383,41 @@ pub const Selector = struct { return self.doTransferToLoop(current); } + /// Native implementation of the Fiber Scheduler #kernel_sleep hook. + /// Replaces the three-way Ruby branch in CarbonFiber::Scheduler so + /// the hot path on every `sleep(...)` call from user code goes from + /// Ruby straight into native, skipping the Ruby proxy method frame. + /// Mirrors the semantics in CarbonFiber::Scheduler#kernel_sleep: + /// nil yields to the loop, non-positive sleeps re-queue the current + /// fiber, positive durations park on a native timer. Always returns + /// Qtrue to match the Ruby version. + pub fn kernel_sleep(self: *Self, duration_val: Value) Value { + _ = Self.InstanceMethods.kernelSleepImpl(self, duration_val); + return Value.from(true); + } + + // Shared body so kernel_sleep can call sibling methods (transfer / + // yield / block) without the Zig method-syntax dance. Returns nil to + // discard the inner methods' return values; kernel_sleep only cares + // about side effects. + fn kernelSleepImpl(self: *Self, duration_val: Value) Value { + self.ensureInitialized(); + if (duration_val.isNil()) { + return Self.InstanceMethods.transfer(self); + } + const duration = floatFromValue(duration_val, 0.0); + if (duration <= 0.0) { + return Self.InstanceMethods.@"yield"(self); + } + return Self.InstanceMethods.block(self, Value.fromRaw(support.rb_fiber_current()), duration_val); + } + + /// Run one event loop iteration. Flushes ready fibers, polls for I/O. pub fn select(self: *Self, timeout_val: Value) Value { self.ensureInitialized(); const timeout = if (timeout_val.isNil()) null else floatFromValue(timeout_val, null); - return Value.from(self.doSelect(timeout) catch + return support.intValue(self.doSelect(timeout) catch Error.raiseRuntimeError("selector.select failed")); } @@ -372,13 +425,13 @@ pub const Selector = struct { pub fn block(self: *Self, fiber_val: Value, timeout_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); const timeout = if (timeout_val.isNil()) null else floatFromValue(timeout_val, null); var timer_token: u64 = 0; // 0 = no timer if (timeout) |seconds| { if (seconds >= 0.0 and std.math.isFinite(seconds)) { - timer_token = self.scheduleTimer(.resume_fiber, fiber, Value.from(false).toRaw(), seconds, null) catch + timer_token = self.scheduleTimer(.resume_fiber, fiber, Value.from(false).asRaw(), seconds, null) catch Error.raiseRuntimeError("Failed to schedule block timeout"); } } @@ -412,7 +465,7 @@ pub const Selector = struct { /// hasPending() true and the scheduler loops forever. pub fn cancel_block_timer(self: *Self, fiber_val: Value) Value { if (!self.initialized) return Value.nil; - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); // Remove from blocked set (so flushReady doesn't mistake a dead // fiber for a live voluntarily-sleeping one) and cancel any // pending sleep timer if still armed. @@ -425,7 +478,7 @@ pub const Selector = struct { /// Resume a fiber previously suspended by block() (thread-safe). pub fn unblock(self: *Self, fiber_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); if (support.rb_thread_current() == self.scheduler_thread) { // Same thread: direct enqueue, no locking or notify needed. // Thread::Queue#push, Mutex#unlock etc. call unblock from the @@ -433,13 +486,13 @@ pub const Selector = struct { // the blocked fiber live in the same scheduler. // The GVL is held, so the scheduler will drain ready_entries // in its next flushReady before blocking. - self.enqueue(.resume_fiber, fiber, Value.from(true).toRaw()) catch + self.enqueue(.resume_fiber, fiber, Value.from(true).asRaw()) catch Error.raiseRuntimeError("Failed to enqueue unblock"); } else { self.enqueueCrossThread(.{ .kind = .resume_fiber, .fiber = fiber, - .payload = Value.from(true).toRaw(), + .payload = Value.from(true).asRaw(), }) catch Error.raiseRuntimeError("Failed to enqueue unblock (cross-thread)"); // Only notify when the selector is blocked in kevent/io_uring. // If not blocked, the scheduler thread holds the GVL and will @@ -457,9 +510,9 @@ pub const Selector = struct { pub fn raise_after(self: *Self, fiber_val: Value, exception_val: Value, duration_val: Value) Value { self.ensureInitialized(); const duration = floatFromValue(duration_val, 0.0); - const token = self.scheduleTimer(.raise, fiber_val.toRaw(), exception_val.toRaw(), duration, null) catch + const token = self.scheduleTimer(.raise, fiber_val.asRaw(), exception_val.asRaw(), duration, null) catch Error.raiseRuntimeError("Failed to schedule raise_after"); - return Value.from(token); + return support.intValue(token); } /// Cancel a pending timer by token. Returns true if cancelled, @@ -474,7 +527,7 @@ pub const Selector = struct { /// Returns readiness bitmask or nil. pub fn io_wait(self: *Self, fiber_val: Value, fd_val: Value, events_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); const fd = integerFromValue(i32, fd_val, "expected fd"); const events = integerFromValue(i16, events_val, "expected io_wait events"); return self.ioWait(fiber, fd, events, null) catch @@ -485,7 +538,7 @@ pub const Selector = struct { /// Returns false on timeout. pub fn io_wait_with_timeout(self: *Self, fiber_val: Value, fd_val: Value, events_val: Value, timeout_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); const fd = integerFromValue(i32, fd_val, "expected fd"); const events = integerFromValue(i16, events_val, "expected io_wait events"); const timeout = floatFromValue(timeout_val, null); @@ -501,7 +554,7 @@ pub const Selector = struct { pub fn io_wait_object(self: *Self, io_val: Value, events_val: Value, timeout_val: Value) Value { self.ensureInitialized(); const fiber = support.rb_fiber_current(); - const fd: i32 = @intCast(support.rb_io_descriptor(io_val.toRaw())); + const fd: i32 = @intCast(support.rb_io_descriptor(io_val.asRaw())); const events = integerFromValue(i16, events_val, "expected io_wait events"); const timeout: ?f64 = if (timeout_val.isNil()) null else floatFromValue(timeout_val, null); return self.ioWait(fiber, fd, events, timeout) catch @@ -512,7 +565,7 @@ pub const Selector = struct { pub fn io_close(self: *Self, fd_val: Value, exception_val: Value) Value { self.ensureInitialized(); const fd = integerFromValue(i32, fd_val, "expected fd"); - self.ioClose(fd, exception_val.toRaw()) catch + self.ioClose(fd, exception_val.asRaw()) catch Error.raiseRuntimeError("selector.io_close failed"); return Value.from(true); } @@ -535,7 +588,7 @@ pub const Selector = struct { /// extract (non-IO object), letting the Ruby caller fall back. pub fn io_read_object(self: *Self, io_val: Value, buffer_val: Value, length_val: Value, offset_val: Value) Value { self.ensureInitialized(); - const fd: i32 = @intCast(support.rb_io_descriptor(io_val.toRaw())); + const fd: i32 = @intCast(support.rb_io_descriptor(io_val.asRaw())); const length = integerFromValue(usize, length_val, "expected read length"); const offset = integerFromValue(usize, offset_val, "expected read offset"); return self.ioRead(fd, buffer_val, length, offset) catch @@ -557,7 +610,7 @@ pub const Selector = struct { /// rationale as io_read_object — skip the Ruby-side fileno dance. pub fn io_write_object(self: *Self, io_val: Value, buffer_val: Value, length_val: Value, offset_val: Value) Value { self.ensureInitialized(); - const fd: i32 = @intCast(support.rb_io_descriptor(io_val.toRaw())); + const fd: i32 = @intCast(support.rb_io_descriptor(io_val.asRaw())); const length = integerFromValue(usize, length_val, "expected write length"); const offset = integerFromValue(usize, offset_val, "expected write offset"); return self.ioWrite(fd, buffer_val, length, offset) catch @@ -567,7 +620,7 @@ pub const Selector = struct { /// Wait for a child process via pidfd/kqueue. Returns Process::Status. pub fn process_wait(self: *Self, fiber_val: Value, pid_val: Value, flags_val: Value) Value { self.ensureInitialized(); - const fiber = fiber_val.toRaw(); + const fiber = fiber_val.asRaw(); const pid = integerFromValue(std.posix.pid_t, pid_val, "expected pid"); const flags = integerFromValue(c_int, flags_val, "expected wait flags"); return self.processWait(fiber, pid, flags) catch @@ -661,7 +714,7 @@ pub const Selector = struct { const ready = try self.waitForPoll(fiber, fd, .write, timeout); if (ready == null) return Value.nil; if (!ready.?) return Value.from(false); - return Value.from(@as(i64, READABLE | WRITABLE)); + return support.intValue(READABLE | WRITABLE); } if ((events & READABLE) != 0) { // Skip pollReadableNow: Ruby calls io_wait after EAGAIN, so the fd @@ -669,13 +722,13 @@ pub const Selector = struct { const ready = try self.waitForPoll(fiber, fd, .read, timeout); if (ready == null) return Value.nil; if (!ready.?) return Value.from(false); - return Value.from(@as(i64, READABLE)); + return support.intValue(READABLE); } if ((events & WRITABLE) != 0) { const ready = try self.waitForPoll(fiber, fd, .write, timeout); if (ready == null) return Value.nil; if (!ready.?) return Value.from(false); - return Value.from(@as(i64, WRITABLE)); + return support.intValue(WRITABLE); } return Value.nil; } @@ -709,7 +762,7 @@ pub const Selector = struct { if (dir == .read) { if (timeout) |seconds| { if (seconds >= 0.0 and std.math.isFinite(seconds)) { - descriptor.read_timeout_token = try self.scheduleTimer(.resume_fiber, fiber, Value.from(false).toRaw(), seconds, descriptor); + descriptor.read_timeout_token = try self.scheduleTimer(.resume_fiber, fiber, Value.from(false).asRaw(), seconds, descriptor); } } } @@ -728,7 +781,7 @@ pub const Selector = struct { // Poll completion resumes with the event mask (truthy integer). // Timeout / external resume (Async timer) transfers nil → not ready. // Poll error resumes with false → not ready. - const raw = result.toRaw(); + const raw = result.asRaw(); return raw != crb.Qfalse and raw != crb.Qnil; } @@ -736,11 +789,11 @@ pub const Selector = struct { // Extract buffer pointer once: reused by both fast path and uring slow path var base: ?*anyopaque = null; var size: usize = 0; - support.rb_io_buffer_get_bytes_for_writing(buffer.toRaw(), &base, &size); + support.rb_io_buffer_get_bytes_for_writing(buffer.asRaw(), &base, &size); - if (offset > size) return Value.from(@as(i64, -@as(isize, @intFromEnum(std.posix.E.INVAL)))); + if (offset > size) return support.intValue(-@as(isize, @intFromEnum(std.posix.E.INVAL))); const available = size - offset; - if (available == 0) return Value.from(@as(i64, 0)); + if (available == 0) return support.intValue(0); const read_len = if (length == 0) available else @min(available, length); const ptr: [*]u8 = @ptrCast(base.?); @@ -768,21 +821,21 @@ pub const Selector = struct { // is empty (typical on UDS request/response pairs and small // HTTP responses). if (length == 0) { - return Value.from(@as(i64, @intCast(rc))); + return support.intValue(rc); } - return Value.from(@as(i64, @intCast(io.drainRecv(fd, ptr + offset, read_len, @intCast(rc))))); + return support.intValue(io.drainRecv(fd, ptr + offset, read_len, @intCast(rc))); } - if (rc == 0) return Value.from(@as(i64, 0)); + if (rc == 0) return support.intValue(0); if (!io.wouldBlockErrno(-rc)) { // Non-socket fd (pipe, file): try read(2) instead of recv if (io.isEnotsock(-rc)) { const rrc = io.readOnce(fd, (ptr + offset)[0..read_len]); - if (rrc > 0) return Value.from(@as(i64, rrc)); - if (rrc == 0) return Value.from(@as(i64, 0)); + if (rrc > 0) return support.intValue(rrc); + if (rrc == 0) return support.intValue(0); // EAGAIN on pipe: need to wait, fall back to Ruby return Value.nil; } - return Value.from(@as(i64, rc)); + return support.intValue(rc); } self.probe_misses[probe_idx] +|= 1; } @@ -791,8 +844,11 @@ pub const Selector = struct { const fiber = support.rb_fiber_current(); if (comptime xev.backend == .io_uring) { const uring_result = try self.ioRecvUring(fd, (ptr + offset)[0..read_len], fiber); - if (uring_result.toRaw() == crb.Qnil) return uring_result; - const n = uring_result.toInt(isize) catch return uring_result; + const raw = uring_result.asRaw(); + if (raw == crb.Qnil or raw == crb.Qfalse) return uring_result; + // Payload is always a Fixnum byte-count from ioRecvCallback; skip + // the dispatch in Value.to(isize) and decode the tag directly. + const n = support.fixnumToIsize(uring_result); if (n <= 0) return uring_result; // Only reset `probe_misses` if we received a burst—more data may be // sitting in the kernel buffer, so the next call's probe is @@ -811,9 +867,9 @@ pub const Selector = struct { // EAGAIN and costs one syscall per read. For sized reads // (length > 0) we still drain to fill the caller's buffer. if (length == 0) { - return Value.from(@as(i64, @intCast(n))); + return support.intValue(n); } - return Value.from(@as(i64, @intCast(io.drainRecv(fd, ptr + offset, read_len, @intCast(n))))); + return support.intValue(io.drainRecv(fd, ptr + offset, read_len, @intCast(n))); } else { return self.ioReadPoll(fd, ptr + offset, read_len, fiber); } @@ -826,14 +882,14 @@ pub const Selector = struct { while (true) { const wait_ready = try self.waitForPoll(fiber, fd, .read, null); if (wait_ready == null) return Value.nil; - if (!wait_ready.?) return Value.from(@as(i64, -@as(isize, @intFromEnum(std.posix.E.AGAIN)))); + if (!wait_ready.?) return support.intValue(-@as(isize, @intFromEnum(std.posix.E.AGAIN))); const rc = io.recvOnce(fd, buf[0..read_len]); - if (rc > 0) return Value.from(@as(i64, @intCast(io.drainRecv(fd, buf, read_len, @intCast(rc))))); + if (rc > 0) return support.intValue(io.drainRecv(fd, buf, read_len, @intCast(rc))); if (rc == 0) { - return Value.from(@as(i64, 0)); + return support.intValue(0); } - if (!io.wouldBlockErrno(-rc)) return Value.from(@as(i64, rc)); + if (!io.wouldBlockErrno(-rc)) return support.intValue(rc); } } @@ -873,11 +929,11 @@ pub const Selector = struct { // Extract buffer pointer once for send + drain var base: ?*const anyopaque = null; var size: usize = 0; - support.rb_io_buffer_get_bytes_for_reading(buffer.toRaw(), &base, &size); + support.rb_io_buffer_get_bytes_for_reading(buffer.asRaw(), &base, &size); - if (offset > size) return Value.from(@as(i64, -@as(isize, @intFromEnum(std.posix.E.INVAL)))); + if (offset > size) return support.intValue(-@as(isize, @intFromEnum(std.posix.E.INVAL))); const available = size - offset; - if (available == 0) return Value.from(@as(i64, 0)); + if (available == 0) return support.intValue(0); const write_len = if (length == 0) available else @min(available, length); const ptr: [*]const u8 = @ptrCast(base.?); const buf = (ptr + offset)[0..write_len]; @@ -888,19 +944,19 @@ pub const Selector = struct { if (rc > 0) { total_sent = io.drainSend(fd, buf.ptr, write_len, @intCast(rc)); if (total_sent >= write_len) { - return Value.from(@as(i64, @intCast(total_sent))); + return support.intValue(total_sent); } } else if (rc == 0) { - return Value.from(@as(i64, 0)); + return support.intValue(0); } else if (!io.wouldBlockErrno(-rc)) { // Non-socket fd (pipe, file): try write(2) instead of send if (io.isEnotsock(-rc)) { const wrc = io.writeOnce(fd, buf); - if (wrc > 0) return Value.from(@as(i64, wrc)); - if (wrc == 0) return Value.from(@as(i64, 0)); + if (wrc > 0) return support.intValue(wrc); + if (wrc == 0) return support.intValue(0); return Value.nil; } - return Value.from(@as(i64, rc)); + return support.intValue(rc); } // Slow path: wait for writability, then send+drain until complete @@ -922,11 +978,11 @@ pub const Selector = struct { } if (total_sent > 0) { - return Value.from(@as(i64, @intCast(total_sent))); + return support.intValue(total_sent); } // Nothing sent at all—return error from last sendOnce - if (rc == 0) return Value.from(@as(i64, 0)); - return Value.from(@as(i64, rc)); + if (rc == 0) return support.intValue(0); + return support.intValue(rc); } fn processWait(self: *Self, fiber: crb.VALUE, pid: std.posix.pid_t, flags: c_int) !Value { @@ -996,7 +1052,7 @@ pub const Selector = struct { } } - fn enqueue(self: *Self, kind: ReadyKind, fiber: crb.VALUE, payload: crb.VALUE) !void { + inline fn enqueue(self: *Self, kind: ReadyKind, fiber: crb.VALUE, payload: crb.VALUE) !void { try self.ready_entries.append(self.allocator, .{ .kind = kind, .fiber = fiber, .payload = payload }); } @@ -1224,14 +1280,10 @@ pub const Selector = struct { // Fast path 2: fiber IS in the map → it re-blocked itself, // so it is guaranteed alive and does NOT need re-enqueuing. // Skips the rb_fiber_alive_p() C call entirely. - if (self.blocked_fibers.count() == 0) { - if (support.fiberAlive(entry.fiber)) { - self.enqueue(.resume_fiber, entry.fiber, crb.Qnil) catch {}; - } - } else if (!self.blocked_fibers.contains(entry.fiber)) { - if (support.fiberAlive(entry.fiber)) { - self.enqueue(.resume_fiber, entry.fiber, crb.Qnil) catch {}; - } + const not_blocked = self.blocked_fibers.count() == 0 or + !self.blocked_fibers.contains(entry.fiber); + if (not_blocked and support.fiberAlive(entry.fiber)) { + self.enqueue(.resume_fiber, entry.fiber, crb.Qnil) catch {}; } }, .raise => { @@ -1405,64 +1457,45 @@ pub const Selector = struct { }; -const GcMode = enum { mark, compact }; - -/// Walk all GC-visible VALUE slots. Mark mode pins values in place; -/// compact mode updates moved references. Comptime dispatch eliminates -/// the duplication between selectorMark and selectorCompact. -fn gcWalkValues(self: *Selector, comptime mode: GcMode) void { - const visit = struct { - inline fn v(ptr: *crb.VALUE) void { - if (mode == .compact) { - ptr.* = support.compactValue(ptr.*); - } else { - support.markValue(ptr.*); - } - } - }.v; - - visit(&self.loop_fiber); - visit(&self.scheduler_thread); +fn selectorMark(data: ?*anyopaque) callconv(.c) void { + const self: *Selector = @ptrCast(@alignCast(data.?)); + support.markValue(self.loop_fiber); + support.markValue(self.scheduler_thread); - for (self.ready_entries.items) |*entry| { - visit(&entry.fiber); - visit(&entry.payload); + for (self.ready_entries.items) |entry| { + support.markValue(entry.fiber); + support.markValue(entry.payload); } - { + // Mirror drainCrossThread's fast path: the pending flag is set with + // release ordering after every cross-thread append, so an acquire load + // observing `false` here means no other thread has anything to mark. + // Skipping the mutex avoids the lock+unlock per GC cycle for the common + // single-threaded scheduler case. + if (self.cross_thread_pending.load(.acquire)) { self.cross_thread_mutex.lock(); defer self.cross_thread_mutex.unlock(); - for (self.cross_thread_entries.items) |*entry| { - visit(&entry.fiber); - visit(&entry.payload); + for (self.cross_thread_entries.items) |entry| { + support.markValue(entry.fiber); + support.markValue(entry.payload); } } - if (mode == .compact) self.timers.compact() else self.timers.mark(); + self.timers.mark(); var it = self.descriptors.iterator(); while (it.next()) |entry| { const descriptor = entry.value_ptr.*; - for (&descriptor.poll) |*state| visit(&state.waiter); + for (descriptor.poll) |state| support.markValue(state.waiter); } - for (self.process_waits.items) |wait| visit(&wait.fiber); + for (self.process_waits.items) |wait| support.markValue(wait.fiber); for (self.retired_descriptors.items) |descriptor| { - for (&descriptor.poll) |*state| visit(&state.waiter); + for (descriptor.poll) |state| support.markValue(state.waiter); } - for (self.retired_process_waits.items) |wait| visit(&wait.fiber); -} - -fn selectorMark(data: ?*anyopaque) callconv(.c) void { - const self: *Selector = @ptrCast(@alignCast(data.?)); - gcWalkValues(self, .mark); -} - -fn selectorCompact(data: ?*anyopaque) callconv(.c) void { - const self: *Selector = @ptrCast(@alignCast(data.?)); - gcWalkValues(self, .compact); + for (self.retired_process_waits.items) |wait| support.markValue(wait.fiber); } fn selectorFree(data: ?*anyopaque) callconv(.c) void { @@ -1509,7 +1542,7 @@ fn completePoll(descriptor: ?*Descriptor, completion: *xev.Completion, ok: bool) state.waiter = crb.Qnil; if (self.active_waiters > 0) self.active_waiters -= 1; const event: i16 = if (dir == .read) READABLE else WRITABLE; - const payload = if (ok) Value.from(@as(i64, event)).toRaw() else Value.from(false).toRaw(); + const payload = if (ok) support.intValue(event).asRaw() else Value.from(false).asRaw(); self.enqueue(.resume_fiber, fiber, payload) catch {}; } @@ -1524,7 +1557,7 @@ fn completePoll(descriptor: ?*Descriptor, completion: *xev.Completion, ok: bool) .udata = 0, .ext = .{ 0, 0 }, }}; - _ = std.c.kevent64(self.loop.kqueue_fd, &kev, 1, &kev, 0, 0, null); + _ = std.c.kevent64(self.loop.kqueue_fd, &kev, 1, &kev, 0, .NONE, null); } return .disarm; @@ -1579,7 +1612,7 @@ fn ioRecvCallback( state.waiter = crb.Qnil; if (self.active_waiters > 0) self.active_waiters -= 1; // Pass io_result as a Ruby Fixnum through the fiber transfer payload - self.enqueue(.resume_fiber, fiber, Value.from(@as(i64, io_result)).toRaw()) catch {}; + self.enqueue(.resume_fiber, fiber, support.intValue(io_result).asRaw()) catch {}; } return .disarm; @@ -1598,7 +1631,7 @@ fn processWaitCallback( const fiber = process_wait.fiber; process_wait.fiber = crb.Qnil; if (process_wait.selector.active_waiters > 0) process_wait.selector.active_waiters -= 1; - process_wait.selector.enqueue(.resume_fiber, fiber, Value.from(true).toRaw()) catch {}; + process_wait.selector.enqueue(.resume_fiber, fiber, Value.from(true).asRaw()) catch {}; } return .disarm; @@ -1639,11 +1672,20 @@ fn waitUnblock(data: ?*anyopaque) callconv(.c) void { } fn floatFromValue(value: Value, fallback: ?f64) f64 { - return value.toFloat(f64) catch fallback orelse Error.raiseArgumentError("expected numeric timeout"); + return value.to(f64) catch fallback orelse Error.raiseArgumentError("expected numeric timeout"); } -fn integerFromValue(comptime T: type, value: Value, message: [:0]const u8) T { - return value.toInt(T) catch Error.raiseArgumentError(message); +inline fn integerFromValue(comptime T: type, value: Value, message: [:0]const u8) T { + // Fixnum fast path: virtually every fd / events / length / offset that + // Ruby passes us is already a Fixnum (small integer). Skip zig.rb's + // Value.to(T), which goes through rb_type dispatch, and decode the + // (n << 1) | 1 tag directly. Bignums and non-numerics fall through to + // the slow path that does the proper dispatch + raises ArgumentError. + const raw = value.asRaw(); + if ((raw & 1) == 1) { + return @intCast(@as(isize, @bitCast(raw)) >> 1); + } + return value.to(T) catch Error.raiseArgumentError(message); } fn ptrToValue(ptr: anytype) crb.VALUE { diff --git a/ext/carbon_fiber_native/support.zig b/ext/carbon_fiber_native/support.zig index a6738ba..09413fe 100644 --- a/ext/carbon_fiber_native/support.zig +++ b/ext/carbon_fiber_native/support.zig @@ -4,6 +4,7 @@ const std = @import("std"); const rb = @import("rb"); pub const crb = rb.crb; +const Value = rb.Value; pub extern fn rb_io_buffer_get_bytes_for_writing(buffer: crb.VALUE, base: *?*anyopaque, size: *usize) void; pub extern fn rb_io_buffer_get_bytes_for_reading(buffer: crb.VALUE, base: *?*const anyopaque, size: *usize) void; @@ -15,8 +16,7 @@ pub extern fn rb_thread_call_without_gvl( data2: ?*anyopaque, ) ?*anyopaque; -pub extern fn rb_gc_mark_movable(value: crb.VALUE) void; -pub extern fn rb_gc_location(value: crb.VALUE) crb.VALUE; +pub extern fn rb_gc_mark_maybe(value: crb.VALUE) void; pub extern fn rb_fiber_current() crb.VALUE; pub extern fn rb_thread_current() crb.VALUE; pub extern fn rb_fiber_alive_p(fiber: crb.VALUE) crb.VALUE; @@ -25,26 +25,51 @@ pub extern fn rb_fiber_raise(fiber: crb.VALUE, argc: c_int, argv: ?[*]const crb. pub extern fn rb_io_descriptor(io: crb.VALUE) c_int; pub fn monotonicSeconds() f64 { - const ts = std.posix.clock_gettime(.MONOTONIC) catch unreachable; + var ts: std.posix.timespec = undefined; + const rc = std.posix.system.clock_gettime(.MONOTONIC, &ts); + std.debug.assert(std.posix.errno(rc) == .SUCCESS); return @as(f64, @floatFromInt(ts.sec)) + (@as(f64, @floatFromInt(ts.nsec)) / 1_000_000_000.0); } +// Encodes a Ruby Fixnum from a Zig integer. Replaces `Value.from(int)` which +// is now @compileError in zig.rb. Inlines the (n << 1) | 1 encoding directly +// rather than going through zig.rb's Fixnum.fromInt to keep the hot path as +// short as possible and to avoid generating extra code paths during LTO. +pub inline fn intValue(value: anytype) Value { + const FixInt = std.meta.Int(.signed, @typeInfo(c_long).int.bits - 1); + const v: FixInt = @intCast(value); + const tagged: crb.VALUE = @bitCast((@as(isize, v) << 1) | 1); + return Value.fromRaw(tagged); +} + +// Decodes a Ruby Fixnum back to an isize. Caller must know `value` is a +// Fixnum: this skips zig.rb's Value.to(...) which dispatches on rb_type and +// otherwise costs an extra Ruby C-API call per byte-count return on the +// hot ioRead/ioWrite paths. +pub inline fn fixnumToIsize(value: Value) isize { + return @as(isize, @bitCast(value.asRaw())) >> 1; +} + pub fn busySpinUntil(deadline: f64) void { while (monotonicSeconds() < deadline) { std.atomic.spinLoopHint(); } } -pub fn fiberAlive(fiber: crb.VALUE) bool { +pub inline fn fiberAlive(fiber: crb.VALUE) bool { return rb_fiber_alive_p(fiber) == crb.Qtrue; } -pub fn markValue(value: crb.VALUE) void { - if (value != crb.Qnil) rb_gc_mark_movable(value); -} - -pub fn compactValue(value: crb.VALUE) crb.VALUE { - if (value == crb.Qnil) return value; - return rb_gc_location(value); +// Marks a queue-resident VALUE using Ruby's conservative marker. +// rb_gc_mark_maybe consults Ruby's heap-arena registry and silently +// ignores anything that isn't a live heap object: immediates skip out +// quickly (their tag bits or zero-LSB-mask fail the heap-pointer +// pre-check), and stale slots like the YJIT + error_highlight + Mutex +// pathology fail the arena lookup. Pre-filtering with +// RB_SPECIAL_CONST_P was tried and measurably regressed +// fan_out_gather: the bit tests have to run on every heap-VALUE path +// too, where they're pure overhead in front of the same arena lookup. +pub inline fn markValue(value: crb.VALUE) void { + rb_gc_mark_maybe(value); } diff --git a/ext/carbon_fiber_native/timer_queue.zig b/ext/carbon_fiber_native/timer_queue.zig index 33dd76c..44f2c2e 100644 --- a/ext/carbon_fiber_native/timer_queue.zig +++ b/ext/carbon_fiber_native/timer_queue.zig @@ -33,18 +33,18 @@ pub const TimerQueue = struct { allocator: std.mem.Allocator, entries: Heap, - actions: std.AutoHashMapUnmanaged(u64, TimerAction) = .{}, + actions: std.AutoHashMapUnmanaged(u64, TimerAction) = .empty, next_token: u64 = 1, pub fn init(allocator: std.mem.Allocator) TimerQueue { return .{ .allocator = allocator, - .entries = Heap.init(allocator, {}), + .entries = .empty, }; } pub fn deinit(self: *TimerQueue) void { - self.entries.deinit(); + self.entries.deinit(self.allocator); self.actions.deinit(self.allocator); } @@ -53,7 +53,7 @@ pub const TimerQueue = struct { self.next_token += 1; try self.actions.put(self.allocator, token, action); errdefer _ = self.actions.remove(token); - try self.entries.add(.{ .deadline = deadline, .token = token }); + try self.entries.push(self.allocator, .{ .deadline = deadline, .token = token }); return token; } @@ -77,7 +77,7 @@ pub const TimerQueue = struct { while (self.entries.peek()) |entry| { if (entry.deadline > now) return null; - _ = self.entries.remove(); + _ = self.entries.pop(); if (self.actions.fetchRemove(entry.token)) |removed| { return removed.value; } @@ -87,32 +87,17 @@ pub const TimerQueue = struct { } pub fn mark(self: *TimerQueue) void { - self.gcWalk(.mark); - } - - pub fn compact(self: *TimerQueue) void { - self.gcWalk(.compact); - } - - const GcMode = enum { mark, compact }; - - fn gcWalk(self: *TimerQueue, comptime mode: GcMode) void { var it = self.actions.iterator(); while (it.next()) |entry| { - if (mode == .compact) { - entry.value_ptr.fiber = support.compactValue(entry.value_ptr.fiber); - entry.value_ptr.payload = support.compactValue(entry.value_ptr.payload); - } else { - support.markValue(entry.value_ptr.fiber); - support.markValue(entry.value_ptr.payload); - } + support.markValue(entry.value_ptr.fiber); + support.markValue(entry.value_ptr.payload); } } fn discardStale(self: *TimerQueue) void { while (self.entries.peek()) |entry| { if (self.actions.contains(entry.token)) break; - _ = self.entries.remove(); + _ = self.entries.pop(); } } }; diff --git a/lib/carbon_fiber/scheduler.rb b/lib/carbon_fiber/scheduler.rb index 0fdb1f2..4f8e088 100644 --- a/lib/carbon_fiber/scheduler.rb +++ b/lib/carbon_fiber/scheduler.rb @@ -169,15 +169,7 @@ def unblock(_blocker, fiber) # Intercept +Kernel#sleep+. Parks the fiber on a native timer. # @param duration [Float, nil] seconds to sleep; nil sleeps forever def kernel_sleep(duration = nil) - if duration.nil? - transfer - elsif duration <= 0 - self.yield - else - block(nil, duration) - end - - true + @selector.kernel_sleep(duration) end # Wait for I/O readiness on a file descriptor. From 03f19b204813c450a623b676abb913e1a636b1fc Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 8 May 2026 18:16:14 +0300 Subject: [PATCH 2/6] Improve benchmarks: warmup runs, CV, carbon_published target --- benchmarks/README.md | 6 ++- benchmarks/async_bench | 73 ++++++++++++++++++-------- benchmarks/async_bench_one | 14 +++-- benchmarks/async_docker | 4 +- benchmarks/bench | 96 +++++++++++++++++++++++++---------- benchmarks/core_docker | 3 +- benchmarks/lib/async_bench.rb | 14 ++++- benchmarks/lib/bench.rb | 9 ++++ 8 files changed, 161 insertions(+), 58 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index db20b83..54866ca 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -46,7 +46,7 @@ Orchestrator. Spawns each (target, workload) combination as an isolated subproce Usage: bench [options] -t, --targets LIST Comma-separated target IDs (default: all) -w, --workloads LIST Comma-separated workload IDs (default: all) - -r, --runs N Number of runs per combo, takes median (default: 1) + -r, --runs N Number of runs per combo, takes median (default: 3) --timeout N Per-workload timeout in seconds (default: 30) --setup Install target gems and exit ``` @@ -62,6 +62,8 @@ Output goes to stdout (the table). Progress and setup messages go to stderr. When exactly 2 targets are specified, a delta column shows the percentage change from the first target to the second. +When `runs >= 2`, each cell is annotated with the coefficient of variation (e.g. `±2.5%`), and the delta column tags entries as `(noisy)` when the magnitude of the change is within either target's CV. Use this to tell genuine regressions/wins from run-to-run jitter. + ### `benchmarks/bench_one` Worker subprocess. Not meant to be called directly. Loads one target scheduler, runs one workload, prints a single floating-point metric value to stdout. @@ -163,7 +165,7 @@ Orchestrator for async benchmarks. Same CLI interface as `bench`. Usage: async_bench [options] -t, --targets LIST Comma-separated target IDs: stock, carbon (default: all) -w, --workloads LIST Comma-separated workload IDs (default: all) - -r, --runs N Number of runs per combo, takes median (default: 1) + -r, --runs N Number of runs per combo, takes median (default: 3) --timeout N Per-workload timeout in seconds (default: 30) ``` diff --git a/benchmarks/async_bench b/benchmarks/async_bench index b2f5fd1..1aa3450 100755 --- a/benchmarks/async_bench +++ b/benchmarks/async_bench @@ -14,20 +14,23 @@ require "optparse" require "timeout" +require "bundler" require_relative "lib/async_bench" # --- CLI --- targets = nil workloads = nil -runs = 1 +runs = 3 +warmup = 1 timeout = Integer(ENV.fetch("BENCH_TIMEOUT", 30)) OptionParser.new do |opts| opts.banner = "Usage: async_bench [options]" opts.on("-t", "--targets LIST", "Comma-separated target IDs") { |v| targets = v.split(",") } opts.on("-w", "--workloads LIST", "Comma-separated workload IDs") { |v| workloads = v.split(",") } - opts.on("-r", "--runs N", Integer, "Number of runs per combo (default: 1)") { |v| runs = v } + opts.on("-r", "--runs N", Integer, "Number of runs per combo (default: 3)") { |v| runs = v } + opts.on("--warmup N", Integer, "Discard the first N runs as warmup (default: 1)") { |v| warmup = v } opts.on("--timeout N", Integer, "Per-workload timeout in seconds (default: 30)") { |v| timeout = v } end.parse! @@ -48,33 +51,47 @@ bench_one = File.join(__dir__, "async_bench_one") gem_home = ENV["BENCH_GEM_HOME"] || File.join(__dir__, ".gems") results = {} -warn "Async bench: #{targets.size} target(s) × #{workloads.size} workload(s) × #{runs} run(s)" +total_runs = warmup + runs +warn "Async bench: #{targets.size} target(s) × #{workloads.size} workload(s) × #{total_runs} run(s)" \ + "#{" (#{warmup} warmup, #{runs} measured)" if warmup.positive?}" warn "" -runs.times do |run| - $stderr.print "Run #{run + 1}/#{runs}: " if runs > 1 +total_runs.times do |run| + is_warmup = run < warmup + label = is_warmup ? "Warmup" : "Run" + index = is_warmup ? run + 1 : run - warmup + 1 + total = is_warmup ? warmup : runs + $stderr.print "#{label} #{index}/#{total}: " if total_runs > 1 targets.each do |t| workloads.each do |w| $stderr.print "#{t}/#{w} " rd, wr = IO.pipe env = {"BENCH_GEM_HOME" => gem_home} - pid = Process.spawn(env, RbConfig.ruby, bench_one, t, w, out: wr, err: "/dev/null") + # See bench's spawn site for why we strip Bundler's env: if this + # script runs under `bundle exec`, RUBYOPT inherits `-rbundler/setup` + # which restricts the child's $LOAD_PATH to the project Gemfile and + # breaks per-target gem requires. + pid = Bundler.with_unbundled_env do + Process.spawn(env, RbConfig.ruby, bench_one, t, w, out: wr, err: "/dev/null") + end wr.close begin Timeout.timeout(timeout) { Process.wait(pid) } if $?.success? value = rd.read.strip.to_f - results[[t, w]] = [] unless results[[t, w]].is_a?(Array) - results[[t, w]] << value + unless is_warmup + results[[t, w]] = [] unless results[[t, w]].is_a?(Array) + results[[t, w]] << value + end else - results[[t, w]] ||= :error + results[[t, w]] ||= :error unless is_warmup end rescue Timeout::Error Process.kill("KILL", pid) Process.wait(pid) - results[[t, w]] ||= :timeout + results[[t, w]] ||= :timeout unless is_warmup ensure rd.close end @@ -83,33 +100,45 @@ runs.times do |run| $stderr.puts end -# --- Compute medians --- +# --- Compute summaries (median, coefficient of variation) --- medians = {} +cvs = {} results.each do |key, val| - medians[key] = if val.is_a?(Array) && !val.empty? + if val.is_a?(Array) && !val.empty? sorted = val.sort - sorted[sorted.size / 2] + medians[key] = sorted[sorted.size / 2] + if sorted.size >= 2 + mean = sorted.sum.to_f / sorted.size + variance = sorted.sum { |x| (x - mean)**2 } / sorted.size + stddev = Math.sqrt(variance) + cvs[key] = mean.zero? ? 0.0 : (stddev / mean * 100) + end else - val + medians[key] = val end end # --- Print markdown table --- -def format_cell(value, unit) +def format_cell(value, unit, cv = nil) return "error" if value == :error return "timeout" if value == :timeout - "#{AsyncBench.fmt(value)} #{unit}" + base = "#{AsyncBench.fmt(value)} #{unit}" + return base if cv.nil? + "#{base} ±#{format("%.1f", cv)}%" end -def format_delta(baseline, current) +def format_delta(baseline, current, baseline_cv = nil, current_cv = nil) return "" if !baseline.is_a?(Numeric) || !current.is_a?(Numeric) || baseline.zero? pct = ((current - baseline) / baseline.abs) * 100 sign = (pct >= 0) ? "+" : "" - "#{sign}#{format("%.1f", pct)}%" + out = "#{sign}#{format("%.1f", pct)}%" + noise = [baseline_cv, current_cv].compact.max + out += " (noisy)" if noise && pct.abs <= noise + out end best_per_workload = {} @@ -132,7 +161,7 @@ cells = {} workloads.each do |w| unit = AsyncBench::WORKLOADS[w][:unit] targets.each do |t| - cell = format_cell(medians[[t, w]], unit) + cell = format_cell(medians[[t, w]], unit, cvs[[t, w]]) cell = "**#{cell}**" if t == best_per_workload[w] cells[[t, w]] = cell end @@ -147,7 +176,8 @@ delta_width = 0 if show_delta deltas = workloads.map do |w| - format_delta(medians[[targets[0], w]], medians[[targets[1], w]]) + format_delta(medians[[targets[0], w]], medians[[targets[1], w]], + cvs[[targets[0], w]], cvs[[targets[1], w]]) end delta_width = [5, *deltas.map(&:size)].max end @@ -168,7 +198,8 @@ workloads.each do |w| targets.each_with_index { |t, i| row += " #{cells[[t, w]].rjust(col_widths[i])} |" } if show_delta - delta = format_delta(medians[[targets[0], w]], medians[[targets[1], w]]) + delta = format_delta(medians[[targets[0], w]], medians[[targets[1], w]], + cvs[[targets[0], w]], cvs[[targets[1], w]]) row += " #{delta.rjust(delta_width)} |" end diff --git a/benchmarks/async_bench_one b/benchmarks/async_bench_one index 9acb07b..7e659b5 100755 --- a/benchmarks/async_bench_one +++ b/benchmarks/async_bench_one @@ -14,12 +14,18 @@ workload_id = ARGV[1] or abort("usage: async_bench_one ") target = AsyncBench::TARGETS.fetch(target_id) { abort("unknown target: #{target_id}") } workload_def = AsyncBench::WORKLOADS.fetch(workload_id) { abort("unknown workload: #{workload_id}") } -# Set up gem path for async +# Set up gem paths. Async always lives in gem_home/async; targets that +# declare `gems:` (e.g. carbon_published) install into gem_home/ +# and need that dir on GEM_PATH alongside async. gem_home = ENV["BENCH_GEM_HOME"] || File.join(__dir__, ".gems") +gem_paths = [] async_home = File.join(gem_home, "async") -if Dir.exist?(async_home) - ENV["GEM_HOME"] = async_home - ENV["GEM_PATH"] = async_home +gem_paths << async_home if Dir.exist?(async_home) +target_home = File.join(gem_home, target_id) +gem_paths << target_home if Dir.exist?(target_home) +unless gem_paths.empty? + ENV["GEM_HOME"] = gem_paths.first + ENV["GEM_PATH"] = gem_paths.join(File::PATH_SEPARATOR) Gem.clear_paths end diff --git a/benchmarks/async_docker b/benchmarks/async_docker index 06fc7c6..e9271bc 100755 --- a/benchmarks/async_docker +++ b/benchmarks/async_docker @@ -22,11 +22,13 @@ docker run --rm \ -v "${ROOT}:/workspace" \ -w /workspace \ -e BENCH_GEM_HOME=/workspace/tmp/linux_gems \ - -e RUBYOPT=--yjit \ + -e RUBYOPT="${BENCH_RUBYOPT:---yjit}" \ + -e BENCH_TIMEOUT \ carbon-fiber-bench \ bash -c ' bundle install --quiet && zig build -Doptimize=ReleaseFast && gem install async --install-dir /workspace/tmp/linux_gems/async --no-document && + gem install carbon_fiber -v 0.1.2 --install-dir /workspace/tmp/linux_gems/carbon_published --no-document && ruby ./benchmarks/async_bench "$@" ' -- "$@" diff --git a/benchmarks/bench b/benchmarks/bench index 0d0d19e..c84ed0c 100755 --- a/benchmarks/bench +++ b/benchmarks/bench @@ -13,17 +13,20 @@ # BENCH_GEM_HOME — per-target gem install root (set by core_docker) # BENCH_TIMEOUT — per-workload timeout in seconds (default: 15) +require "English" require "optparse" require "timeout" require "rbconfig" require "fileutils" +require "bundler" require_relative "lib/bench" # --- CLI --- targets = nil workloads = nil -runs = 1 +runs = 3 +warmup = 1 timeout = Integer(ENV.fetch("BENCH_TIMEOUT", 15)) setup = false @@ -31,7 +34,8 @@ OptionParser.new do |opts| opts.banner = "Usage: bench [options]" opts.on("-t", "--targets LIST", "Comma-separated target IDs") { |v| targets = v.split(",") } opts.on("-w", "--workloads LIST", "Comma-separated workload IDs") { |v| workloads = v.split(",") } - opts.on("-r", "--runs N", Integer, "Number of runs per combo (default: 1)") { |v| runs = v } + opts.on("-r", "--runs N", Integer, "Number of runs per combo (default: 3)") { |v| runs = v } + opts.on("--warmup N", Integer, "Discard the first N runs as warmup (default: 1)") { |v| warmup = v } opts.on("--timeout N", Integer, "Per-workload timeout in seconds (default: 15)") { |v| timeout = v } opts.on("--setup", "Install target gems and exit") { setup = true } end.parse! @@ -72,7 +76,7 @@ def resolve_ruby(version) return nil unless mise path = `#{mise} where ruby #{version} 2>/dev/null`.strip - if $?.success? && !path.empty? + if $CHILD_STATUS.success? && !path.empty? bin = File.join(path, "bin", "ruby") return bin if File.executable?(bin) end @@ -88,7 +92,7 @@ def default_ruby if mise # Try latest installed Ruby via mise path = `#{mise} where ruby 2>/dev/null`.strip - if $?.success? && !path.empty? + if $CHILD_STATUS.success? && !path.empty? bin = File.join(path, "bin", "ruby") return bin if File.executable?(bin) end @@ -142,36 +146,52 @@ end bench_one = File.join(__dir__, "bench_one") ruby_cache = {} gem_home = ENV["BENCH_GEM_HOME"] || File.join(__dir__, ".gems") -results = {} # { [target, workload] => [values] | :error | :timeout } - -warn "Running #{targets.size} target(s) × #{workloads.size} workload(s) × #{runs} run(s)" +results = {} # { [target, workload] => [values] | :error | :timeout } + +# Each iteration spawns a fresh Ruby per (target, workload), so the warmup +# runs absorb YJIT compilation, library load, and first-call instruction +# cache misses inside that subprocess; their results are recorded but not +# used for the summary. +total_runs = warmup + runs +warn "Running #{targets.size} target(s) × #{workloads.size} workload(s) × #{total_runs} run(s)" \ + "#{" (#{warmup} warmup, #{runs} measured)" if warmup.positive?}" warn "" -runs.times do |run| - $stderr.print "Run #{run + 1}/#{runs}: " if runs > 1 +total_runs.times do |run| + is_warmup = run < warmup + label = is_warmup ? "Warmup" : "Run" + index = is_warmup ? run + 1 : run - warmup + 1 + total = is_warmup ? warmup : runs + $stderr.print "#{label} #{index}/#{total}: " if total_runs > 1 targets.each do |t| workloads.each do |w| $stderr.print "#{t}/#{w} " rd, wr = IO.pipe target_ruby = ruby_for_target(Bench::TARGETS[t], ruby_cache) + env = {"BENCH_GEM_HOME" => gem_home} - pid = Process.spawn(env, target_ruby, bench_one, t, w, out: wr, err: "/dev/null") + pid = Bundler.with_unbundled_env do + Process.spawn(env, target_ruby, bench_one, t, w, out: wr, err: "/dev/null") + end wr.close begin Timeout.timeout(timeout) { Process.wait(pid) } - if $?.success? + if $CHILD_STATUS.success? value = rd.read.strip.to_f - results[[t, w]] = [] unless results[[t, w]].is_a?(Array) - results[[t, w]] << value + # Only count post-warmup runs in the result set used for the table. + unless is_warmup + results[[t, w]] = [] unless results[[t, w]].is_a?(Array) + results[[t, w]] << value + end else - results[[t, w]] ||= :error + results[[t, w]] ||= :error unless is_warmup end rescue Timeout::Error Process.kill("KILL", pid) Process.wait(pid) - results[[t, w]] ||= :timeout + results[[t, w]] ||= :timeout unless is_warmup ensure rd.close end @@ -180,33 +200,52 @@ runs.times do |run| $stderr.puts end -# --- Compute medians --- +# --- Compute summaries (median, coefficient of variation) --- +# Run-to-run noise on can dwarf real per-target +# differences, so expose CV alongside the median: cell width stays small +# but the reader can tell "this cell is ±8% noisy" from "this cell is ±0.2% +# stable" at a glance. medians = {} +cvs = {} results.each do |key, val| - medians[key] = if val.is_a?(Array) && !val.empty? + if val.is_a?(Array) && !val.empty? sorted = val.sort - sorted[sorted.size / 2] + medians[key] = sorted[sorted.size / 2] + if sorted.size >= 2 + mean = sorted.sum.to_f / sorted.size + variance = sorted.sum { |x| (x - mean)**2 } / sorted.size + stddev = Math.sqrt(variance) + cvs[key] = mean.zero? ? 0.0 : (stddev / mean * 100) + end else - val # :error or :timeout + medians[key] = val # :error or :timeout end end # --- Print markdown table --- -def format_cell(value, unit) +def format_cell(value, unit, cv = nil) return "error" if value == :error return "timeout" if value == :timeout - "#{Bench.fmt(value)} #{unit}" + base = "#{Bench.fmt(value)} #{unit}" + return base if cv.nil? + + "#{base} ±#{format("%.1f", cv)}%" end -def format_delta(baseline, current) +def format_delta(baseline, current, baseline_cv = nil, current_cv = nil) return "" if !baseline.is_a?(Numeric) || !current.is_a?(Numeric) || baseline.zero? pct = ((current - baseline) / baseline.abs) * 100 sign = (pct >= 0) ? "+" : "" - "#{sign}#{format("%.1f", pct)}%" + out = "#{sign}#{format("%.1f", pct)}%" + # Annotate when the delta is within either target's noise envelope so + # readers know not to over-read the headline number. + noise = [baseline_cv, current_cv].compact.max + out += " (noisy)" if noise && pct.abs <= noise + out end # Find best (highest numeric) result per workload for bolding @@ -216,7 +255,8 @@ workloads.each do |w| best_target = nil targets.each do |t| v = medians[[t, w]] - next unless v.is_a?(Numeric) && v > 0 + next unless v.is_a?(Numeric) && v.positive? + if best_val.nil? || v > best_val best_val = v best_target = t @@ -231,7 +271,7 @@ cells = {} workloads.each do |w| unit = Bench::WORKLOADS[w][:unit] targets.each do |t| - cell = format_cell(medians[[t, w]], unit) + cell = format_cell(medians[[t, w]], unit, cvs[[t, w]]) cell = "**#{cell}**" if t == best_per_workload[w] cells[[t, w]] = cell end @@ -246,7 +286,8 @@ delta_width = 0 if show_delta deltas = workloads.map do |w| - format_delta(medians[[targets[0], w]], medians[[targets[1], w]]) + format_delta(medians[[targets[0], w]], medians[[targets[1], w]], + cvs[[targets[0], w]], cvs[[targets[1], w]]) end delta_width = [5, *deltas.map(&:size)].max end @@ -270,7 +311,8 @@ workloads.each do |w| targets.each_with_index { |t, i| row += " #{cells[[t, w]].rjust(col_widths[i])} |" } if show_delta - delta = format_delta(medians[[targets[0], w]], medians[[targets[1], w]]) + delta = format_delta(medians[[targets[0], w]], medians[[targets[1], w]], + cvs[[targets[0], w]], cvs[[targets[1], w]]) row += " #{delta.rjust(delta_width)} |" end diff --git a/benchmarks/core_docker b/benchmarks/core_docker index c79fa53..fe9acc3 100755 --- a/benchmarks/core_docker +++ b/benchmarks/core_docker @@ -30,6 +30,7 @@ docker run --rm \ "${UPSTREAM_MOUNT[@]}" \ -w /workspace \ -e BENCH_GEM_HOME=/workspace/tmp/linux_gems \ - -e RUBYOPT=--yjit \ + -e RUBYOPT="${BENCH_RUBYOPT:---yjit}" \ + -e BENCH_TIMEOUT \ carbon-fiber-bench \ bash -c 'bundle install --quiet && zig build -Doptimize=ReleaseFast && ruby ./benchmarks/bench --setup "$@" && ruby ./benchmarks/bench "$@"' -- "$@" diff --git a/benchmarks/lib/async_bench.rb b/benchmarks/lib/async_bench.rb index 3f7a1e0..0f92e08 100644 --- a/benchmarks/lib/async_bench.rb +++ b/benchmarks/lib/async_bench.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "benchmark" - module AsyncBench TARGETS = { "stock" => { @@ -14,6 +12,18 @@ module AsyncBench require_relative "../../lib/carbon_fiber/async" CarbonFiber::Async.default! } + }, + # Latest carbon_fiber from RubyGems, paired with Async, for A/B + # against the local source. async_bench_one scopes GEM_HOME per-target, + # so `require "carbon_fiber"` resolves from the installed gem. + "carbon_published" => { + gems: [["carbon_fiber", "0.1.2"]], + setup: -> { + require "carbon_fiber" + require "async" + require "carbon_fiber/async" + CarbonFiber::Async.default! + } } }.freeze diff --git a/benchmarks/lib/bench.rb b/benchmarks/lib/bench.rb index e844e0a..67fd805 100644 --- a/benchmarks/lib/bench.rb +++ b/benchmarks/lib/bench.rb @@ -6,6 +6,15 @@ module Bench require_proc: -> { require_relative "../../lib/carbon_fiber" }, constant: "CarbonFiber::Scheduler" }, + # Latest carbon_fiber from RubyGems, for A/B against the local source. + # GEM_HOME is scoped per-target by bench_one, and the require_proc is + # `require "carbon_fiber"` (not require_relative), so resolution comes + # from the gem dir rather than the in-tree lib/. + "carbon_published" => { + gems: [["carbon_fiber", "0.1.2"]], + require_proc: -> { require "carbon_fiber" }, + constant: "CarbonFiber::Scheduler" + }, "async" => { gems: [["async", "2.38.1"]], require_proc: -> { require "async" }, From 862e71afcc49f2931dd82e03593b69f4016b9dad Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 8 May 2026 18:16:33 +0300 Subject: [PATCH 3/6] Fix benchmark workloads: condition_signal deadlock, cascading_timeout portability --- .../async_bench/workloads/condition_signal.rb | 42 +++++++++---------- .../lib/bench/workloads/cascading_timeout.rb | 7 +++- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/benchmarks/lib/async_bench/workloads/condition_signal.rb b/benchmarks/lib/async_bench/workloads/condition_signal.rb index 9e0f1e9..25975fa 100644 --- a/benchmarks/lib/async_bench/workloads/condition_signal.rb +++ b/benchmarks/lib/async_bench/workloads/condition_signal.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# Producer/consumer signaling via Async::Condition. -# Multiple producers signal conditions that multiple consumers wait on. -# Exercises the selector's fiber wakeup and transfer paths. +# Producer/consumer signaling via Async::Queue. +# Multiple producers push to a shared queue that multiple consumers pop. +# Exercises the selector's fiber wakeup and transfer paths under contention. module AsyncBench module Workloads @@ -15,41 +15,39 @@ def call(options) started_at = AsyncBench.monotonic_time Async do - barrier = Async::Barrier.new - # Shared condition + counter for flow control - condition = Async::Condition.new - produced = 0 - consumed = 0 + consumer_barrier = Async::Barrier.new + producer_barrier = Async::Barrier.new + queue = Async::Queue.new total = producers * messages - # Consumers: wait for signals, count receipts consumers.times do - barrier.async do + consumer_barrier.async do loop do - condition.wait - consumed += 1 - break if consumed >= total + item = queue.dequeue + break if item == :drain end end end - # Producers: signal the condition, small sleep between bursts producers.times do - barrier.async do + producer_barrier.async do messages.times do |i| - produced += 1 - condition.signal(produced) - # Yield periodically to let consumers run + queue.enqueue(i) + # Yield periodically to let consumers run. sleep(0.0001) if (i % 10).zero? end end end - # Wait for all producers to finish - # Then signal remaining consumers to unblock - barrier.wait + producer_barrier.wait + # Send one drain marker per consumer so every consumer's pop() + # observes a sentinel and exits its loop. Buffered queue, so + # markers never get lost even if a consumer is mid-yield. + consumers.times { queue.enqueue(:drain) } + consumer_barrier.wait + total rescue - # Barrier.wait may raise if consumer tasks error on shutdown + # Barrier.wait may raise if consumer tasks error on shutdown. nil end diff --git a/benchmarks/lib/bench/workloads/cascading_timeout.rb b/benchmarks/lib/bench/workloads/cascading_timeout.rb index 03ace88..c9942dd 100644 --- a/benchmarks/lib/bench/workloads/cascading_timeout.rb +++ b/benchmarks/lib/bench/workloads/cascading_timeout.rb @@ -28,7 +28,12 @@ def call(_scheduler, options) slow = ((iter * 7 + index * 3) % 10) < 3 begin - Fiber.scheduler.timeout_after(TIMEOUT_DURATION, Timeout::Error, "benchmark timeout") do + # `Timeout.timeout` dispatches to `Fiber.scheduler.timeout_after` + # when the scheduler implements it, otherwise falls back to a + # Thread-based timer; this keeps the workload portable across + # schedulers that haven't adopted the (non-protocol) + # `timeout_after` extension yet (e.g. Itsi). + Timeout.timeout(TIMEOUT_DURATION, Timeout::Error, "benchmark timeout") do sleep(slow ? SLOW_WORK : FAST_WORK) local_completed += 1 end From 82b95e0b687d6bc98b122c9473c72c1916522a80 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 8 May 2026 18:16:57 +0300 Subject: [PATCH 4/6] Rakefile: keep macOS bundle loadable after rpath scrub --- Gemfile.lock | 4 ++-- Rakefile | 37 +++++++++++++++++++------------------ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b6f788b..104a5be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,7 +35,7 @@ GEM prism (1.9.0) racc (1.8.1) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rake-compiler-dock (1.11.1) regexp_parser (2.12.0) rspec (3.13.2) @@ -124,7 +124,7 @@ CHECKSUMS prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rake-compiler-dock (1.11.1) sha256=9234ded6c3311f4ba6db77f2833f0aa33c073b4d33c896a2156e2551af828e0a regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 diff --git a/Rakefile b/Rakefile index cb02719..aa753c0 100644 --- a/Rakefile +++ b/Rakefile @@ -20,14 +20,9 @@ task :sync_version do end end -# Zig 0.15's Mach-O linker has issues with Xcode 26.4 -# - https://codeberg.org/ziglang/zig/pulls/31673 -# - https://codeberg.org/ziglang/zig/issues/31658 -# Workaround: DEVELOPER_DIR=/dev/null makes Zig use its bundled libSystem.tbd. desc "Compile the Zig native extension (also syncs version)" task compile: :sync_version do - env = RUBY_PLATFORM.include?("darwin") ? {"DEVELOPER_DIR" => "/dev/null"} : {} - sh env, "zig", "build", "-Doptimize=ReleaseFast" + sh "zig", "build", "-Doptimize=ReleaseFast" fix_macos_install_names if RUBY_PLATFORM.include?("darwin") end @@ -57,12 +52,18 @@ def fix_macos_install_names sh "install_name_tool", "-delete_rpath", rpath, bundle if rpath.start_with?("/") end + # Add a portable rpath so the bundle can locate libruby on any host. + unless rpaths.include?("@executable_path/../lib") + sh "install_name_tool", "-add_rpath", "@executable_path/../lib", bundle + end + sh "codesign", "--sign", "-", "--force", bundle final_libs = `otool -L #{bundle.shellescape}` leaked = final_libs.lines.find do |l| next false unless l.start_with?("\t/") next false if l.match?(%r{^\t/usr/lib/}) || l.match?(%r{^\t/System/}) + true end raise "Non-portable dylib in #{bundle}: #{leaked.strip}\n\n#{final_libs}" if leaked @@ -73,26 +74,26 @@ def fix_macos_install_names end end -ZIG_VERSION = "0.15.2" +ZIG_VERSION = "0.16.0" # Ruby versions to compile for. Full version must exactly match the directory # name inside the RCD image: docker run --rm ls /usr/local/rake-compiler/ruby/x86_64-linux-gnu/ RUBY_CROSS_VERSIONS = [ {full: "3.4.8", api: "3.4.0"}, {full: "4.0.0", api: "4.0.0"} -] +].freeze # [gem_platform_name, rcd_platform (image suffix), zig_target_triple] # rcd_platform also names the dir inside the RCD container *most* of the time; # scripts/rcd_build.sh discovers the actual path because the x86_64-musl image # uses x86_64-unknown-linux-musl as its top dir. LINUX_PLATFORMS = [ - ["x86_64-linux", "x86_64-linux-gnu", "x86_64-linux-gnu"], - ["aarch64-linux", "aarch64-linux-gnu", "aarch64-linux-gnu"], - ["x86_64-linux-musl", "x86_64-linux-musl", "x86_64-linux-musl"], - ["aarch64-linux-musl", "aarch64-linux-musl", "aarch64-linux-musl"] -] -DARWIN_PLATFORMS = %w[arm64-darwin] + %w[x86_64-linux x86_64-linux-gnu x86_64-linux-gnu], + %w[aarch64-linux aarch64-linux-gnu aarch64-linux-gnu], + %w[x86_64-linux-musl x86_64-linux-musl x86_64-linux-musl], + %w[aarch64-linux-musl aarch64-linux-musl aarch64-linux-musl] +].freeze +DARWIN_PLATFORMS = %w[arm64-darwin].freeze ALL_PLATFORMS = LINUX_PLATFORMS.map(&:first) + DARWIN_PLATFORMS # Cross-compile tasks @@ -103,11 +104,11 @@ namespace :cross do task gem_platform do require "rake_compiler_dock" - build_cmds = RUBY_CROSS_VERSIONS.map { |r| + build_cmds = RUBY_CROSS_VERSIONS.map do |r| "RUBY_FULL_VERSION=#{r[:full]} RUBY_API_VERSION=#{r[:api]} " \ "RCD_PLATFORM=#{rcd_platform} TARGET_TRIPLE=#{zig_triple} " \ "ZIG_VERSION=#{ZIG_VERSION} bash scripts/rcd_build.sh" - }.join(" && ") + end.join(" && ") RakeCompilerDock.sh(build_cmds, platform: rcd_platform) end @@ -132,7 +133,7 @@ def build_platform_gem(platform) spec = Gem::Specification.load("carbon_fiber.gemspec") spec.platform = Gem::Platform.new(platform) - spec.extensions = [] # pre-built; no compilation on install + spec.extensions = [] # pre-built; no compilation on install ext = platform.include?("darwin") ? "bundle" : "so" files = Dir["lib/**/*.rb"].reject { |f| File.directory?(f) } + @@ -210,4 +211,4 @@ task :lint do end desc "Compile native extension and run specs" -task default: [:compile, :spec] +task default: %i[compile spec] From 472ce6a67edeb44da8726012cb98e9169a33c1bc Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 8 May 2026 18:40:08 +0300 Subject: [PATCH 5/6] Add kernel_sleep to the pure-Ruby fallback Selector --- lib/carbon_fiber/native/fallback.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/carbon_fiber/native/fallback.rb b/lib/carbon_fiber/native/fallback.rb index c1c68e9..b76cb28 100644 --- a/lib/carbon_fiber/native/fallback.rb +++ b/lib/carbon_fiber/native/fallback.rb @@ -124,6 +124,22 @@ def select(timeout = nil) flush_ready end + # Mirrors `Selector#kernel_sleep` on the native side so + # `Scheduler#kernel_sleep` can delegate to `@selector.kernel_sleep` + # in both paths. Branches on the duration: nil parks the fiber on + # the loop without a timer, non-positive yields, positive parks on + # a native timer for `duration` seconds. + def kernel_sleep(duration = nil) + if duration.nil? + transfer + elsif duration <= 0 + self.yield + else + block(Fiber.current, duration) + end + true + end + # Suspend the current fiber until unblocked or timed out. def block(fiber, timeout = nil) token = nil From ddb0270fed9f30b9524fad58b4b235e96f11100b Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 8 May 2026 18:40:28 +0300 Subject: [PATCH 6/6] Don't link libruby on Linux: build a fresh rb module from zig_rb sources --- build.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/build.zig b/build.zig index b71eb25..9d83c63 100644 --- a/build.zig +++ b/build.zig @@ -13,7 +13,16 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); - const rb_module = zig_rb_dep.module("zig_rb"); + // zig_rb's exposed module has a `linkSystemLibrary("ruby")` baked in + // for its own test runner; consuming it pulls libruby into our .so's + // NEEDED list, which conflicts with the system libruby on hosts that + // also ship Ruby (e.g. ubuntu-latest). Build a fresh module from the + // same source to avoid that propagation. + const rb_module = b.createModule(.{ + .root_source_file = zig_rb_dep.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); const fibers_module = b.createModule(.{ .root_source_file = b.path("ext/carbon_fiber_native/main.zig"),