Module: Hyperion::IOUring

Defined in:
lib/hyperion/io_uring.rb

Overview

2.3-A — io_uring accept on Linux 5.6+ (opt-in).

The biggest unmovable bottleneck below the GVL on the plaintext h1 path is the kernel accept loop: every accept costs accept_nonblock + IO.select on the EAGAIN edge (two syscalls per accepted connection under burst). io_uring lets us submit accept SQEs and reap CQEs in one syscall, with the kernel batching multiple accepts in a single CQE drain when connections arrive faster than the fiber can consume them.

## Surface

Hyperion::IOUring.supported?   # bool — Linux ≥ 5.6 + cdylib loaded
                               #        + runtime probe succeeds
Hyperion::IOUring::Ring.new(queue_depth: 256)
                               # per-fiber ring; #accept(fd) → fd
                               # or :wouldblock; #close releases
                               # the ring's SQ/CQ memory.

## Per-fiber, NEVER per-process or per-thread

io_uring under fork+threads has known sharp edges:

* Submission queue is process-shared by default — under fork, the
  parent's outstanding SQEs leak into the child's CQ.
* IORING_SETUP_SQPOLL kernel thread does not survive fork.
* Threads sharing a ring need IORING_SETUP_SINGLE_ISSUER + careful
  submission discipline.

Hyperion’s safe pattern, matching the fiber-per-conn architecture:

* One ring per fiber that needs it (the accept fiber, optionally
  per-connection read fibers in a future phase).
* Ring is opened lazily on first use:
    Fiber.current[:hyperion_io_uring] ||=
      Hyperion::IOUring::Ring.new(queue_depth: 256)
* Ring is closed when the fiber exits.
* Workers don't share rings across fork — each child opens its own.

## Default off in 2.3.0

Mirrors the 2.2.0 fix-B HYPERION_H2_NATIVE_HPACK pattern: ship the plumbing in 2.3.0 with the default OFF, give operators an env-var to A/B (HYPERION_IO_URING=on,auto), flip the default to :auto in 2.4 only after 6 months of soak. io_uring code in production has too many sharp edges to default-on without field validation.

Defined Under Namespace

Classes: Ring, Unsupported

Constant Summary collapse

EXPECTED_ABI =
1
MIN_LINUX_KERNEL =

Linux 5.6 stabilized IORING_OP_ACCEPT (commit 17f2fe35d080, mainlined Mar 2020). 5.5 had a buggy precursor that the io-uring crate refuses to use. We gate on 5.6 to match the crate’s stance.

[5, 6].freeze

Class Method Summary collapse

Class Method Details

.candidate_pathsObject



259
260
261
262
263
264
265
# File 'lib/hyperion/io_uring.rb', line 259

def candidate_paths
  gem_lib = File.expand_path('../hyperion_io_uring', __dir__)
  ext_target = File.expand_path('../../ext/hyperion_io_uring/target/release', __dir__)
  %w[libhyperion_io_uring.dylib libhyperion_io_uring.so].flat_map do |name|
    [File.join(gem_lib, name), File.join(ext_target, name)]
  end
end

.compute_supportedObject

—- Internal: feature gate —-



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/hyperion/io_uring.rb', line 163

def compute_supported
  # Gate 1: Linux only. macOS/BSD don't have io_uring.
  return false unless linux?

  # Gate 2: Kernel ≥ 5.6.
  return false unless kernel_supports_io_uring?

  # Gate 3: cdylib loaded.
  load!
  return false unless @lib

  # Gate 4: runtime probe — try to set up a tiny ring. Catches
  # sandboxed containers (seccomp blocking io_uring_setup,
  # locked-down environments returning -EPERM, kernels with
  # io_uring disabled via /proc/sys/kernel/io_uring_disabled).
  rc = @probe_fn.call
  rc.zero?
rescue StandardError
  false
end

.kernel_supports_io_uring?Boolean

Returns:

  • (Boolean)


190
191
192
193
194
195
196
197
198
199
# File 'lib/hyperion/io_uring.rb', line 190

def kernel_supports_io_uring?
  return false unless linux?

  release = parse_kernel_release
  return false unless release

  major, minor = release
  min_major, min_minor = MIN_LINUX_KERNEL
  major > min_major || (major == min_major && minor >= min_minor)
end

.linux?Boolean

Returns:

  • (Boolean)


184
185
186
187
188
# File 'lib/hyperion/io_uring.rb', line 184

def linux?
  Etc.uname[:sysname] == 'Linux'
rescue StandardError
  false
end

.load!Object

—- Internal: Fiddle loader —-



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/hyperion/io_uring.rb', line 219

def load!
  return @lib if defined?(@lib) && !@lib.nil?

  path = candidate_paths.find { |p| File.exist?(p) }
  unless path
    @lib = nil
    return nil
  end

  @lib = Fiddle.dlopen(path)
  @abi_fn = Fiddle::Function.new(@lib['hyperion_io_uring_abi_version'],
                                 [], Fiddle::TYPE_INT)
  abi = @abi_fn.call
  if abi != EXPECTED_ABI
    warn "[hyperion] IOUring ABI mismatch (got #{abi}, expected #{EXPECTED_ABI}); falling back"
    @lib = nil
    return nil
  end

  @probe_fn = Fiddle::Function.new(@lib['hyperion_io_uring_probe'],
                                   [], Fiddle::TYPE_INT)
  @ring_new_fn = Fiddle::Function.new(@lib['hyperion_io_uring_ring_new'],
                                      [Fiddle::TYPE_INT], Fiddle::TYPE_VOIDP)
  @ring_free_fn = Fiddle::Function.new(@lib['hyperion_io_uring_ring_free'],
                                       [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
  @accept_fn = Fiddle::Function.new(@lib['hyperion_io_uring_accept'],
                                    [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
                                    Fiddle::TYPE_INT)
  @read_fn = Fiddle::Function.new(@lib['hyperion_io_uring_read'],
                                  [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
                                   Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
                                   Fiddle::TYPE_VOIDP],
                                  Fiddle::TYPE_INT)
  @lib
rescue Fiddle::DLError, StandardError => e
  warn "[hyperion] IOUring failed to load (#{e.class}: #{e.message}); falling back to epoll"
  @lib = nil
  nil
end

.parse_kernel_releaseObject

Etc.uname` is the canonical source. Falls back to `/proc/sys/kernel/osrelease` when uname isn’t available (e.g. specs that stub Etc.uname but leave release alone).



204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/hyperion/io_uring.rb', line 204

def parse_kernel_release
  release = Etc.uname[:release].to_s
  if release.empty? && File.exist?('/proc/sys/kernel/osrelease')
    release = File.read('/proc/sys/kernel/osrelease').strip
  end
  m = release.match(/\A(\d+)\.(\d+)/)
  return nil unless m

  [m[1].to_i, m[2].to_i]
rescue StandardError
  nil
end

.reset!Object

Test seam: clear cached probe so ‘supported?` re-runs. Used by specs that stub Etc.uname or RbConfig.



156
157
158
159
# File 'lib/hyperion/io_uring.rb', line 156

def reset!
  @supported = nil
  @lib = nil
end

.resolve_policy!(policy) ⇒ Object

Resolve the operator’s ‘io_uring` policy + the runtime gate into a boolean “use io_uring on this server”. Called by Server at boot.

Policy values:

:off  → never. Returns false. Used for the 2.3.0 default.
:auto → use it when supported; quietly fall back otherwise.
:on   → demand it. Raise UnsupportedError if not available
        so the operator's misconfig surfaces at boot, not as
        a slow-fallback mystery hours later.


299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/hyperion/io_uring.rb', line 299

def self.resolve_policy!(policy)
  case policy
  when :off, nil, false
    false
  when :auto
    supported?
  when :on, true
    unless supported?
      raise Unsupported,
            'io_uring required (io_uring: :on) but not supported on this host ' \
            "(linux=#{linux?}, kernel_ok=#{kernel_supports_io_uring?}, lib_loaded=#{!@lib.nil?})"
    end
    true
  else
    raise ArgumentError, "io_uring must be :off, :auto, or :on (got #{policy.inspect})"
  end
end

.ring_accept(ptr, fd, errno_buf) ⇒ Object



278
279
280
# File 'lib/hyperion/io_uring.rb', line 278

def ring_accept(ptr, fd, errno_buf)
  @accept_fn.call(ptr, fd, errno_buf)
end

.ring_free(ptr) ⇒ Object



274
275
276
# File 'lib/hyperion/io_uring.rb', line 274

def ring_free(ptr)
  @ring_free_fn.call(ptr)
end

.ring_new(depth) ⇒ Object

—- FFI wrappers —-



269
270
271
272
# File 'lib/hyperion/io_uring.rb', line 269

def ring_new(depth)
  ptr = @ring_new_fn.call(depth)
  ptr.null? ? nil : ptr
end

.ring_read(ptr, fd, buf, max, errno_buf) ⇒ Object



282
283
284
# File 'lib/hyperion/io_uring.rb', line 282

def ring_read(ptr, fd, buf, max, errno_buf)
  @read_fn.call(ptr, fd, buf, max, errno_buf)
end

.supported?Boolean

Cached three-state result: nil = not-yet-probed, true/false = result.

The probe is intentionally process-local (not Fiber-local) — the answer is the same for every fiber in this process, and probing once at boot avoids per-request syscall overhead.

Returns:

  • (Boolean)


148
149
150
151
152
# File 'lib/hyperion/io_uring.rb', line 148

def supported?
  return @supported unless @supported.nil?

  @supported = compute_supported
end