hyperion-rb 2.16.3 → 2.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/ext/hyperion_http/extconf.rb +9 -0
- data/ext/hyperion_http/parser.c +93 -21
- data/ext/hyperion_http/response_writer.c +604 -0
- data/ext/hyperion_http/response_writer.h +28 -0
- data/ext/hyperion_io_uring/Cargo.lock +1 -1
- data/ext/hyperion_io_uring/Cargo.toml +1 -1
- data/ext/hyperion_io_uring/src/buffer_ring.rs +319 -0
- data/ext/hyperion_io_uring/src/hotpath.rs +645 -0
- data/ext/hyperion_io_uring/src/lib.rs +5 -1
- data/lib/hyperion/cli.rb +23 -0
- data/lib/hyperion/config.rb +9 -0
- data/lib/hyperion/connection.rb +209 -1
- data/lib/hyperion/http/response_writer.rb +46 -0
- data/lib/hyperion/io_uring.rb +270 -5
- data/lib/hyperion/response_writer.rb +91 -1
- data/lib/hyperion/server.rb +200 -4
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion.rb +1 -0
- metadata +6 -1
data/lib/hyperion/cli.rb
CHANGED
|
@@ -44,6 +44,7 @@ module Hyperion
|
|
|
44
44
|
# known sharp edges (SQ inheritance, SQPOLL non-survival across
|
|
45
45
|
# fork). The env var is the sanctioned way to opt in.
|
|
46
46
|
apply_io_uring_env_override!(config)
|
|
47
|
+
apply_io_uring_hotpath_env_override!(config)
|
|
47
48
|
|
|
48
49
|
# 2.3-B: env-var overrides for the per-conn fairness cap and the
|
|
49
50
|
# TLS handshake CPU throttle. Same precedence rule as the other
|
|
@@ -186,6 +187,10 @@ module Hyperion
|
|
|
186
187
|
'Run plain HTTP/1.1 connections under Async::Scheduler (required for hyperion-async-pg and other fiber-cooperative I/O; default off)') do |v|
|
|
187
188
|
cli_opts[:async_io] = v
|
|
188
189
|
end
|
|
190
|
+
o.on('--io-uring-hotpath POLICY',
|
|
191
|
+
'io_uring hot path policy (off|auto|on); default off; Linux 5.19+ only') do |v|
|
|
192
|
+
cli_opts[:io_uring_hotpath] = v.to_sym
|
|
193
|
+
end
|
|
189
194
|
o.on('--max-body-bytes BYTES', Integer,
|
|
190
195
|
'Maximum request body size in bytes (default 16777216 = 16 MiB)') do |n|
|
|
191
196
|
cli_opts[:max_body_bytes] = n
|
|
@@ -321,6 +326,7 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
|
|
|
321
326
|
tls_session_cache_size: config.tls.session_cache_size,
|
|
322
327
|
tls_ktls: config.tls.ktls,
|
|
323
328
|
io_uring: config.io_uring,
|
|
329
|
+
io_uring_hotpath: config.io_uring_hotpath,
|
|
324
330
|
max_in_flight_per_conn: config.max_in_flight_per_conn,
|
|
325
331
|
tls_handshake_rate_limit: config.tls.handshake_rate_limit,
|
|
326
332
|
preload_static_dirs: config.resolved_preload_static_dirs)
|
|
@@ -515,6 +521,23 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
|
|
|
515
521
|
end
|
|
516
522
|
private_class_method :apply_io_uring_env_override!
|
|
517
523
|
|
|
524
|
+
# Plan #2 — env override for the hotpath gate.
|
|
525
|
+
def self.apply_io_uring_hotpath_env_override!(config)
|
|
526
|
+
raw = ENV['HYPERION_IO_URING_HOTPATH']
|
|
527
|
+
return unless raw
|
|
528
|
+
|
|
529
|
+
case raw.downcase
|
|
530
|
+
when 'off', '0', 'false' then config.io_uring_hotpath = :off
|
|
531
|
+
when 'on', '1', 'true' then config.io_uring_hotpath = :on
|
|
532
|
+
when 'auto' then config.io_uring_hotpath = :auto
|
|
533
|
+
else
|
|
534
|
+
Hyperion.logger.warn do
|
|
535
|
+
{ message: 'HYPERION_IO_URING_HOTPATH ignored (must be off|on|auto)', value: raw }
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
private_class_method :apply_io_uring_hotpath_env_override!
|
|
540
|
+
|
|
518
541
|
# 2.3-B: shared parser for `--max-in-flight-per-conn VALUE` and
|
|
519
542
|
# `HYPERION_MAX_IN_FLIGHT_PER_CONN=VALUE`. Returns either a positive
|
|
520
543
|
# Integer (explicit cap) or the `:auto` sentinel which `Config#finalize!`
|
data/lib/hyperion/config.rb
CHANGED
|
@@ -47,6 +47,15 @@ module Hyperion
|
|
|
47
47
|
# Default flips to :auto in 2.4 only after soak. Operators flip on
|
|
48
48
|
# via `HYPERION_IO_URING={on,auto}` env var to A/B test.
|
|
49
49
|
io_uring: :off,
|
|
50
|
+
# Plan #2 (perf roadmap) — io_uring hot path policy. Independent
|
|
51
|
+
# gate from the accept-only `io_uring:` above. Tri-state:
|
|
52
|
+
# :off — accept and read/write stay on the existing paths
|
|
53
|
+
# (default; no behavior change in 2.18 minor cut).
|
|
54
|
+
# :auto — engage when supported (Linux 5.19+ + buffer-ring
|
|
55
|
+
# registration succeeds); quietly fall back otherwise.
|
|
56
|
+
# :on — demand it. Boot raises if unsupported.
|
|
57
|
+
# Override at runtime via `HYPERION_IO_URING_HOTPATH={off,auto,on}`.
|
|
58
|
+
io_uring_hotpath: :off,
|
|
50
59
|
# 2.3-B: per-connection in-flight cap. nginx upstream keep-alive
|
|
51
60
|
# pipelines many client requests through one upstream connection;
|
|
52
61
|
# without this cap a single greedy upstream conn can hog the worker
|
data/lib/hyperion/connection.rb
CHANGED
|
@@ -76,11 +76,26 @@ module Hyperion
|
|
|
76
76
|
|
|
77
77
|
def initialize(parser: self.class.default_parser, writer: ResponseWriter.new, thread_pool: nil,
|
|
78
78
|
log_requests: nil, max_body_bytes: MAX_BODY_BYTES, runtime: nil,
|
|
79
|
-
max_in_flight_per_conn: nil, path_templater: nil, route_table: nil
|
|
79
|
+
max_in_flight_per_conn: nil, path_templater: nil, route_table: nil,
|
|
80
|
+
io_uring_owned: false, app: nil)
|
|
80
81
|
@parser = parser
|
|
81
82
|
@writer = writer
|
|
82
83
|
@thread_pool = thread_pool
|
|
83
84
|
@max_body_bytes = max_body_bytes
|
|
85
|
+
# Plan #2 Task 2.4.1 — Rack app reference for io_uring-owned connections.
|
|
86
|
+
# On the regular (non-hotpath) path the app is passed to Connection#serve
|
|
87
|
+
# as a parameter and never stored. On the io_uring hotpath, Connection#serve
|
|
88
|
+
# is never called; instead the accept fiber drives dispatch via
|
|
89
|
+
# feed_read_bytes → drive_pending_requests, which needs the app stored.
|
|
90
|
+
# Nil on non-io_uring connections; those still use the serve(socket, app)
|
|
91
|
+
# call signature.
|
|
92
|
+
@app = app
|
|
93
|
+
# Plan #2 Task 2.3.4 — when true, this connection's reads are
|
|
94
|
+
# delivered via feed_read_bytes by the accept fiber's OP_RECV
|
|
95
|
+
# handler. read_chunk must NOT be called on io_uring-owned
|
|
96
|
+
# connections (the socket is not polled via read_nonblock on
|
|
97
|
+
# the hotpath). Default false preserves existing behaviour.
|
|
98
|
+
@io_uring_owned = io_uring_owned
|
|
84
99
|
# 2.3-B: per-conn fairness cap. nil disables the check entirely
|
|
85
100
|
# (the hot path stays branchless). Positive integer sets the
|
|
86
101
|
# in-flight ceiling. The counter + dedup-warn flag live as ivars
|
|
@@ -1071,6 +1086,193 @@ module Hyperion
|
|
|
1071
1086
|
end
|
|
1072
1087
|
end
|
|
1073
1088
|
|
|
1089
|
+
# Plan #2 Task 2.3.4 — hotpath recv entry point.
|
|
1090
|
+
#
|
|
1091
|
+
# Called by the accept fiber's OP_RECV completion handler when a
|
|
1092
|
+
# multishot-recv CQE arrives carrying bytes from the kernel buffer
|
|
1093
|
+
# ring. `bytes` is a String copied from the kernel-mmaped buffer
|
|
1094
|
+
# slot (one allocation per CQE); the kernel can recycle the slot the
|
|
1095
|
+
# moment `ring.release_buffer(buf_id)` is called in the accept fiber
|
|
1096
|
+
# (AFTER this method returns).
|
|
1097
|
+
#
|
|
1098
|
+
# The method appends `bytes` to the per-connection read accumulator
|
|
1099
|
+
# (`@inbuf`, pre-sized once per connection), then drives the parse
|
|
1100
|
+
# loop to completion. Any complete request found is dispatched via
|
|
1101
|
+
# `dispatch_request` (Rack adapter path) synchronously in the accept
|
|
1102
|
+
# fiber. Carry-over bytes (pipelined input) remain in `@inbuf` for
|
|
1103
|
+
# the next CQE.
|
|
1104
|
+
#
|
|
1105
|
+
# Returns :need_more when no complete HTTP header block was found yet,
|
|
1106
|
+
# :dispatched when at least one request was parsed and dispatched, or
|
|
1107
|
+
# :eof when the buffer is empty (peer closed cleanly before sending
|
|
1108
|
+
# a header). The return value is informational; the accept fiber can
|
|
1109
|
+
# use it to decide whether to dispatch or simply await more CQEs.
|
|
1110
|
+
#
|
|
1111
|
+
# NOTE: this method runs in the accept fiber (single-threaded, GVL
|
|
1112
|
+
# held). No mutex is needed on @inbuf.
|
|
1113
|
+
def feed_read_bytes(bytes)
|
|
1114
|
+
@inbuf ||= String.new(capacity: INBUF_INITIAL_CAPACITY,
|
|
1115
|
+
encoding: Encoding::ASCII_8BIT)
|
|
1116
|
+
@inbuf << bytes
|
|
1117
|
+
|
|
1118
|
+
return :eof if @inbuf.empty?
|
|
1119
|
+
return :need_more unless @inbuf.include?(HEADER_TERM)
|
|
1120
|
+
|
|
1121
|
+
# At least one complete header block is present — drive parse + dispatch.
|
|
1122
|
+
drive_pending_requests
|
|
1123
|
+
end
|
|
1124
|
+
public :feed_read_bytes
|
|
1125
|
+
|
|
1126
|
+
# Plan #2 Task 2.4.1 — parse-and-dispatch loop for io_uring-owned
|
|
1127
|
+
# connections. Called by feed_read_bytes after appending CQE bytes
|
|
1128
|
+
# to @inbuf. Mirrors the core of Connection#serve's request loop but
|
|
1129
|
+
# uses @socket / @app ivars (set at accept time) instead of locals.
|
|
1130
|
+
#
|
|
1131
|
+
# Returns :dispatched when at least one request was fully processed,
|
|
1132
|
+
# :need_more when @inbuf holds data but not a complete request yet, or
|
|
1133
|
+
# :eof when @inbuf is empty after carry-over trimming.
|
|
1134
|
+
#
|
|
1135
|
+
# Runs in the accept fiber (single-threaded, GVL held). No mutex needed
|
|
1136
|
+
# on @inbuf or any of the per-connection ivars it touches.
|
|
1137
|
+
def drive_pending_requests
|
|
1138
|
+
@hijacked = false unless defined?(@hijacked)
|
|
1139
|
+
@last_response_was_static_inline_blocking = false \
|
|
1140
|
+
unless defined?(@last_response_was_static_inline_blocking)
|
|
1141
|
+
|
|
1142
|
+
dispatched_any = false
|
|
1143
|
+
|
|
1144
|
+
loop do
|
|
1145
|
+
# Fast-fail: no complete headers yet.
|
|
1146
|
+
break unless @inbuf.include?(HEADER_TERM)
|
|
1147
|
+
|
|
1148
|
+
# Attempt to parse one request from @inbuf. @inbuf IS the buffer
|
|
1149
|
+
# (not a copy) — carry_into_inbuf! trims it in-place after parse.
|
|
1150
|
+
begin
|
|
1151
|
+
request, body_end = @parser.parse(@inbuf)
|
|
1152
|
+
rescue ParseError => e
|
|
1153
|
+
@metrics.increment(:parse_errors)
|
|
1154
|
+
@logger.warn { { message: 'parse error (hotpath)', error: e.message,
|
|
1155
|
+
error_class: e.class.name } }
|
|
1156
|
+
begin
|
|
1157
|
+
@socket.write("HTTP/1.1 400 Bad Request\r\ncontent-length: 11\r\n" \
|
|
1158
|
+
"connection: close\r\n\r\nBad Request")
|
|
1159
|
+
rescue StandardError
|
|
1160
|
+
nil
|
|
1161
|
+
end
|
|
1162
|
+
@inbuf.clear
|
|
1163
|
+
break
|
|
1164
|
+
rescue UnsupportedError => e
|
|
1165
|
+
@logger.warn { { message: 'unsupported request (hotpath)', error: e.message,
|
|
1166
|
+
error_class: e.class.name } }
|
|
1167
|
+
begin
|
|
1168
|
+
@socket.write("HTTP/1.1 501 Not Implemented\r\ncontent-length: 15\r\n" \
|
|
1169
|
+
"connection: close\r\n\r\nNot Implemented")
|
|
1170
|
+
rescue StandardError
|
|
1171
|
+
nil
|
|
1172
|
+
end
|
|
1173
|
+
@inbuf.clear
|
|
1174
|
+
break
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# Snapshot hijack-buffered carry BEFORE trimming @inbuf.
|
|
1178
|
+
@hijack_buffered = if @inbuf.bytesize > body_end
|
|
1179
|
+
@inbuf.byteslice(body_end, @inbuf.bytesize - body_end).b
|
|
1180
|
+
else
|
|
1181
|
+
EMPTY_BIN
|
|
1182
|
+
end
|
|
1183
|
+
carry_into_inbuf!(@inbuf, body_end)
|
|
1184
|
+
|
|
1185
|
+
peer_addr = peer_address(@socket)
|
|
1186
|
+
request = enrich_with_peer(request, peer_addr) if peer_addr && request.peer_address.nil?
|
|
1187
|
+
|
|
1188
|
+
@metrics.increment(:bytes_read, body_end)
|
|
1189
|
+
@metrics.increment(:requests_total)
|
|
1190
|
+
@metrics.increment(:requests_in_flight)
|
|
1191
|
+
@metrics.increment_labeled_counter(Hyperion::Metrics::REQUESTS_DISPATCH_TOTAL,
|
|
1192
|
+
@worker_id_label_tuple)
|
|
1193
|
+
|
|
1194
|
+
@response_dispatch_mode = nil
|
|
1195
|
+
request_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1196
|
+
|
|
1197
|
+
# 2.10-D direct-dispatch fast path.
|
|
1198
|
+
if @route_table && (direct_handler = @route_table.lookup(request.method, request.path))
|
|
1199
|
+
dispatch_direct!(@socket, request, direct_handler, request_started_at, peer_addr)
|
|
1200
|
+
dispatched_any = true
|
|
1201
|
+
# keep-alive: loop to process any pipelined requests already in @inbuf.
|
|
1202
|
+
next if should_keep_alive_after_direct?(request)
|
|
1203
|
+
|
|
1204
|
+
break
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
# Per-conn fairness gate (mirrors serve's logic).
|
|
1208
|
+
skip_per_conn_fairness = @last_response_was_static_inline_blocking
|
|
1209
|
+
unless @max_in_flight_per_conn.nil? || skip_per_conn_fairness || \
|
|
1210
|
+
per_conn_admit!(@socket, peer_addr)
|
|
1211
|
+
@metrics.decrement(:requests_in_flight)
|
|
1212
|
+
dispatched_any = true
|
|
1213
|
+
next
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
begin
|
|
1217
|
+
status, headers, body = call_app(@app, request)
|
|
1218
|
+
ensure
|
|
1219
|
+
@metrics.decrement(:requests_in_flight)
|
|
1220
|
+
per_conn_release! if @max_in_flight_per_conn && !skip_per_conn_fairness
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
# Rack hijack: app took the socket — stop processing.
|
|
1224
|
+
if @hijacked
|
|
1225
|
+
@logger.debug do
|
|
1226
|
+
{ message: 'rack hijack (hotpath)', method: request.method,
|
|
1227
|
+
path: request.path, peer_addr: peer_addr }
|
|
1228
|
+
end
|
|
1229
|
+
body.close if body.respond_to?(:close)
|
|
1230
|
+
break
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
keep_alive = should_keep_alive?(request, status, headers)
|
|
1234
|
+
@writer.write(@socket, status, headers, body, keep_alive: keep_alive,
|
|
1235
|
+
dispatch_mode: @response_dispatch_mode)
|
|
1236
|
+
@last_response_was_static_inline_blocking =
|
|
1237
|
+
@response_dispatch_mode == :inline_blocking
|
|
1238
|
+
@metrics.increment_status(status)
|
|
1239
|
+
log_request(request, status, request_started_at) if @log_requests
|
|
1240
|
+
observe_request_duration(request, status, request_started_at)
|
|
1241
|
+
dispatched_any = true
|
|
1242
|
+
|
|
1243
|
+
# Stop looping if the response told the peer we're closing.
|
|
1244
|
+
break unless keep_alive
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
if dispatched_any
|
|
1248
|
+
:dispatched
|
|
1249
|
+
elsif @inbuf.empty?
|
|
1250
|
+
:eof
|
|
1251
|
+
else
|
|
1252
|
+
:need_more
|
|
1253
|
+
end
|
|
1254
|
+
rescue StandardError => e
|
|
1255
|
+
@metrics.increment(:app_errors)
|
|
1256
|
+
@logger.error do
|
|
1257
|
+
{ message: 'unhandled in drive_pending_requests', error: e.message,
|
|
1258
|
+
error_class: e.class.name }
|
|
1259
|
+
end
|
|
1260
|
+
:error
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
# Plan #2 Task 2.3.4 — called by the accept fiber when the OP_RECV
|
|
1264
|
+
# CQE result is <= 0 (peer EOF or error). Decrements the active-
|
|
1265
|
+
# connection gauge that was incremented at accept time (normally
|
|
1266
|
+
# handled in Connection#serve's ensure block — that block is NOT
|
|
1267
|
+
# reached for io_uring-owned connections whose lifecycle is managed
|
|
1268
|
+
# entirely by the accept fiber).
|
|
1269
|
+
#
|
|
1270
|
+
# The socket itself is closed by the accept fiber after this returns.
|
|
1271
|
+
def close_for_eof
|
|
1272
|
+
@metrics.decrement(:connections_active)
|
|
1273
|
+
end
|
|
1274
|
+
public :close_for_eof
|
|
1275
|
+
|
|
1074
1276
|
# Read up to READ_CHUNK bytes, returning whatever's available. Unlike
|
|
1075
1277
|
# IO#read(N) — which blocks until N bytes or EOF — read_nonblock returns
|
|
1076
1278
|
# as soon as any data arrives, which is what we need for live HTTP
|
|
@@ -1084,6 +1286,12 @@ module Hyperion
|
|
|
1084
1286
|
# against stalled peers (SO_RCVTIMEO and IO#timeout= don't reliably trip
|
|
1085
1287
|
# readpartial on Ruby 3.3).
|
|
1086
1288
|
def read_chunk(socket)
|
|
1289
|
+
# Plan #2 Task 2.3.4 — io_uring-owned connections receive data
|
|
1290
|
+
# via feed_read_bytes from the accept fiber's OP_RECV handler.
|
|
1291
|
+
# read_chunk must not be called on these connections; the accept
|
|
1292
|
+
# fiber never calls Connection#serve for hotpath conns.
|
|
1293
|
+
raise 'BUG: read_chunk called on io_uring-owned connection' if @io_uring_owned
|
|
1294
|
+
|
|
1087
1295
|
result = socket.read_nonblock(READ_CHUNK, exception: false)
|
|
1088
1296
|
return result if result.is_a?(String) # hot path: data was buffered, return immediately
|
|
1089
1297
|
return nil if result.nil? # EOF
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperion
|
|
4
|
+
module Http
|
|
5
|
+
# Direct-syscall response writer for plain-TCP kernel fds.
|
|
6
|
+
#
|
|
7
|
+
# The C primitives are registered as singleton methods on this
|
|
8
|
+
# very module by `ext/hyperion_http/response_writer.c` (see
|
|
9
|
+
# `Init_hyperion_response_writer`). Surface from C:
|
|
10
|
+
#
|
|
11
|
+
# ResponseWriter.available? -> true | false
|
|
12
|
+
# ResponseWriter.c_write_buffered(io, status, headers, body,
|
|
13
|
+
# keep_alive, date_str) -> Integer
|
|
14
|
+
# ResponseWriter.c_write_chunked(io, status, headers, body,
|
|
15
|
+
# keep_alive, date_str) -> Integer
|
|
16
|
+
# ResponseWriter.c_write_buffered_via_ring(io, status, headers,
|
|
17
|
+
# body, keep_alive,
|
|
18
|
+
# date_str, ring_ptr)
|
|
19
|
+
# -> Integer
|
|
20
|
+
# Plan #2 seam: submits a send SQE via the io_uring crate instead
|
|
21
|
+
# of issuing writev directly. Falls back to c_write_buffered when
|
|
22
|
+
# the io_uring crate is not loaded (hyp_submit_send_fn == NULL
|
|
23
|
+
# after lazy dlsym attempt). ring_ptr is the HotpathRing raw
|
|
24
|
+
# pointer as an Integer.
|
|
25
|
+
#
|
|
26
|
+
# Operators can flip the dispatcher off at runtime with
|
|
27
|
+
# `Hyperion::Http::ResponseWriter.c_writer_available = false`
|
|
28
|
+
# (test seam / A/B rollback). Mirrors the
|
|
29
|
+
# `Hyperion::ResponseWriter.page_cache_available = false`
|
|
30
|
+
# pattern (response_writer.rb:60-65).
|
|
31
|
+
module ResponseWriter
|
|
32
|
+
class << self
|
|
33
|
+
attr_writer :c_writer_available
|
|
34
|
+
|
|
35
|
+
def c_writer_available?
|
|
36
|
+
return @c_writer_available unless @c_writer_available.nil?
|
|
37
|
+
|
|
38
|
+
@c_writer_available =
|
|
39
|
+
respond_to?(:available?) && available? &&
|
|
40
|
+
respond_to?(:c_write_buffered) &&
|
|
41
|
+
respond_to?(:c_write_chunked)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/hyperion/io_uring.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Hyperion
|
|
|
39
39
|
# * One ring per fiber that needs it (the accept fiber, optionally
|
|
40
40
|
# per-connection read fibers in a future phase).
|
|
41
41
|
# * Ring is opened lazily on first use:
|
|
42
|
-
# Fiber
|
|
42
|
+
# Fiber[:hyperion_io_uring] ||=
|
|
43
43
|
# Hyperion::IOUring::Ring.new(queue_depth: 256)
|
|
44
44
|
# * Ring is closed when the fiber exits.
|
|
45
45
|
# * Workers don't share rings across fork — each child opens its own.
|
|
@@ -52,7 +52,7 @@ module Hyperion
|
|
|
52
52
|
# 2.4 only after 6 months of soak. io_uring code in production has
|
|
53
53
|
# too many sharp edges to default-on without field validation.
|
|
54
54
|
module IOUring
|
|
55
|
-
EXPECTED_ABI =
|
|
55
|
+
EXPECTED_ABI = 2
|
|
56
56
|
# Linux 5.6 stabilized IORING_OP_ACCEPT (commit 17f2fe35d080,
|
|
57
57
|
# mainlined Mar 2020). 5.5 had a buggy precursor that the io-uring
|
|
58
58
|
# crate refuses to use. We gate on 5.6 to match the crate's stance.
|
|
@@ -139,6 +139,123 @@ module Hyperion
|
|
|
139
139
|
end
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
# Plan #2 — io_uring hot path: multishot accept + multishot recv
|
|
143
|
+
# with kernel-managed buffer rings + send SQEs. One ring per
|
|
144
|
+
# worker; the accept fiber drains the unified completion queue.
|
|
145
|
+
class HotpathRing
|
|
146
|
+
DEFAULT_QUEUE_DEPTH = 1024
|
|
147
|
+
DEFAULT_N_BUFS = 512
|
|
148
|
+
DEFAULT_BUF_SIZE = 8192
|
|
149
|
+
|
|
150
|
+
# Layout matches the Rust `#[repr(C)] Completion` struct
|
|
151
|
+
# (hotpath.rs has a compile-time assert of size = 24):
|
|
152
|
+
# u8 op_kind | i32 fd | i64 result | i32 buf_id | u32 flags
|
|
153
|
+
# padded to native alignment. Total: 24 bytes.
|
|
154
|
+
COMPLETION_BYTES = 24
|
|
155
|
+
MAX_BATCH = 64
|
|
156
|
+
|
|
157
|
+
# Op-kind values must match Rust's `OpKind` enum in hotpath.rs.
|
|
158
|
+
OP_ACCEPT = 1
|
|
159
|
+
OP_RECV = 2
|
|
160
|
+
OP_SEND = 3
|
|
161
|
+
OP_CLOSE = 4
|
|
162
|
+
|
|
163
|
+
def initialize(queue_depth: DEFAULT_QUEUE_DEPTH,
|
|
164
|
+
n_bufs: DEFAULT_N_BUFS,
|
|
165
|
+
buf_size: DEFAULT_BUF_SIZE)
|
|
166
|
+
raise Unsupported, 'io_uring hotpath not supported on this host' \
|
|
167
|
+
unless IOUring.hotpath_supported?
|
|
168
|
+
|
|
169
|
+
@ptr = IOUring.hotpath_ring_new(queue_depth, n_bufs, buf_size)
|
|
170
|
+
raise Unsupported, 'hotpath ring allocation failed' if @ptr.nil?
|
|
171
|
+
|
|
172
|
+
@completion_buf = Fiddle::Pointer.malloc(COMPLETION_BYTES * MAX_BATCH,
|
|
173
|
+
Fiddle::RUBY_FREE)
|
|
174
|
+
@closed = false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def submit_accept_multishot(listener_fd)
|
|
178
|
+
rc = IOUring.hotpath_submit_accept(@ptr, listener_fd.to_i)
|
|
179
|
+
raise SystemCallError.new('hotpath submit_accept', -rc) if rc.negative?
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def submit_recv_multishot(fd)
|
|
184
|
+
rc = IOUring.hotpath_submit_recv(@ptr, fd.to_i)
|
|
185
|
+
raise SystemCallError.new('hotpath submit_recv', -rc) if rc.negative?
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def submit_send(fd, iov_ptr, iov_count)
|
|
190
|
+
rc = IOUring.hotpath_submit_send(@ptr, fd.to_i, iov_ptr, iov_count.to_i)
|
|
191
|
+
raise SystemCallError.new('hotpath submit_send', -rc) if rc.negative?
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Drain up to MAX_BATCH completions. Yields each as a frozen
|
|
196
|
+
# Hash; returns the count yielded. Caller is responsible for
|
|
197
|
+
# `release_buffer(buf_id)` after consuming a recv buffer view.
|
|
198
|
+
#
|
|
199
|
+
# If wait_completions returns -1 (ring went unhealthy), this
|
|
200
|
+
# method returns 0 yielded and the caller must check `healthy?`
|
|
201
|
+
# to detect the state and fall back to accept4.
|
|
202
|
+
def each_completion(min_complete: 1, timeout_ms: 100)
|
|
203
|
+
n = IOUring.hotpath_wait(@ptr, min_complete, timeout_ms,
|
|
204
|
+
@completion_buf, MAX_BATCH)
|
|
205
|
+
return 0 if n.negative?
|
|
206
|
+
|
|
207
|
+
n.times do |i|
|
|
208
|
+
offset = i * COMPLETION_BYTES
|
|
209
|
+
op_kind = @completion_buf[offset, 1].unpack1('C')
|
|
210
|
+
fd = @completion_buf[offset + 4, 4].unpack1('l<')
|
|
211
|
+
result = @completion_buf[offset + 8, 8].unpack1('q<')
|
|
212
|
+
buf_id = @completion_buf[offset + 16, 4].unpack1('l<')
|
|
213
|
+
flags = @completion_buf[offset + 20, 4].unpack1('L<')
|
|
214
|
+
yield(op_kind: op_kind, fd: fd, result: result,
|
|
215
|
+
buf_id: buf_id, flags: flags)
|
|
216
|
+
end
|
|
217
|
+
n
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def release_buffer(buf_id)
|
|
221
|
+
IOUring.hotpath_release_buf(@ptr, buf_id.to_i)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Plan #2 Task 2.3.4 — copy `len` bytes from kernel buffer-ring
|
|
225
|
+
# slot `buf_id` into a freshly-allocated Ruby String.
|
|
226
|
+
#
|
|
227
|
+
# The caller MUST call `release_buffer(buf_id)` after this returns
|
|
228
|
+
# so the kernel can reuse the slot. One allocation per recv CQE —
|
|
229
|
+
# acceptable as a baseline; the zero-copy variant is deferred.
|
|
230
|
+
def copy_buffer(buf_id, len)
|
|
231
|
+
out = Fiddle::Pointer.malloc(len, Fiddle::RUBY_FREE)
|
|
232
|
+
rc = IOUring.hotpath_copy_buffer(@ptr, buf_id.to_i, len.to_i, out, len.to_i)
|
|
233
|
+
raise SystemCallError.new('hotpath copy_buffer', -rc) if rc.negative?
|
|
234
|
+
out.to_str(rc)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def force_unhealthy!
|
|
238
|
+
IOUring.hotpath_force_unhealthy(@ptr)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def healthy?
|
|
242
|
+
IOUring.hotpath_is_healthy(@ptr) == 1
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def close
|
|
246
|
+
return if @closed
|
|
247
|
+
@closed = true
|
|
248
|
+
IOUring.hotpath_ring_free(@ptr) if @ptr && !@ptr.null?
|
|
249
|
+
@ptr = nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def closed?
|
|
253
|
+
@closed
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
attr_reader :ptr
|
|
257
|
+
end
|
|
258
|
+
|
|
142
259
|
class << self
|
|
143
260
|
# Cached three-state result: nil = not-yet-probed, true/false = result.
|
|
144
261
|
#
|
|
@@ -155,9 +272,29 @@ module Hyperion
|
|
|
155
272
|
# specs that stub Etc.uname or RbConfig.
|
|
156
273
|
def reset!
|
|
157
274
|
@supported = nil
|
|
275
|
+
@hotpath_supported = nil
|
|
158
276
|
@lib = nil
|
|
159
277
|
end
|
|
160
278
|
|
|
279
|
+
# Plan #2 — true when (a) accept-only `supported?` true, AND
|
|
280
|
+
# (b) the kernel actually accepts pbuf-ring registration (5.19+).
|
|
281
|
+
# NOTE: this does NOT probe RecvMulti (6.0+); the Ruby caller
|
|
282
|
+
# must treat the first recv CQE failure as a feature-unavailable
|
|
283
|
+
# signal — see hotpath.rs's hyperion_io_uring_hotpath_supported
|
|
284
|
+
# doc comment for the full kernel-version matrix.
|
|
285
|
+
def hotpath_supported?
|
|
286
|
+
return @hotpath_supported unless @hotpath_supported.nil?
|
|
287
|
+
@hotpath_supported = compute_hotpath_supported
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def compute_hotpath_supported
|
|
291
|
+
return false unless supported?
|
|
292
|
+
return false unless @hotpath_supported_fn
|
|
293
|
+
@hotpath_supported_fn.call.zero?
|
|
294
|
+
rescue StandardError
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
|
|
161
298
|
# ---- Internal: feature gate ----
|
|
162
299
|
|
|
163
300
|
def compute_supported
|
|
@@ -249,6 +386,63 @@ module Hyperion
|
|
|
249
386
|
Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
|
|
250
387
|
Fiddle::TYPE_VOIDP],
|
|
251
388
|
Fiddle::TYPE_INT)
|
|
389
|
+
|
|
390
|
+
# Plan #2 — hotpath surface (5.19+ for PBUF_RING, 6.0+ for RecvMulti).
|
|
391
|
+
@hotpath_supported_fn = Fiddle::Function.new(
|
|
392
|
+
@lib['hyperion_io_uring_hotpath_supported'], [], Fiddle::TYPE_INT
|
|
393
|
+
)
|
|
394
|
+
@hotpath_ring_new_fn = Fiddle::Function.new(
|
|
395
|
+
@lib['hyperion_io_uring_hotpath_ring_new'],
|
|
396
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_SHORT, Fiddle::TYPE_INT],
|
|
397
|
+
Fiddle::TYPE_VOIDP
|
|
398
|
+
)
|
|
399
|
+
@hotpath_ring_free_fn = Fiddle::Function.new(
|
|
400
|
+
@lib['hyperion_io_uring_hotpath_ring_free'],
|
|
401
|
+
[Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID
|
|
402
|
+
)
|
|
403
|
+
@hotpath_submit_accept_fn = Fiddle::Function.new(
|
|
404
|
+
@lib['hyperion_io_uring_hotpath_submit_accept_multishot'],
|
|
405
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT
|
|
406
|
+
)
|
|
407
|
+
@hotpath_submit_recv_fn = Fiddle::Function.new(
|
|
408
|
+
@lib['hyperion_io_uring_hotpath_submit_recv_multishot'],
|
|
409
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT
|
|
410
|
+
)
|
|
411
|
+
@hotpath_submit_send_fn = Fiddle::Function.new(
|
|
412
|
+
@lib['hyperion_io_uring_hotpath_submit_send'],
|
|
413
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
|
|
414
|
+
Fiddle::TYPE_INT
|
|
415
|
+
)
|
|
416
|
+
@hotpath_wait_fn = Fiddle::Function.new(
|
|
417
|
+
@lib['hyperion_io_uring_hotpath_wait_completions'],
|
|
418
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_INT,
|
|
419
|
+
Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
|
|
420
|
+
Fiddle::TYPE_INT
|
|
421
|
+
)
|
|
422
|
+
@hotpath_release_buf_fn = Fiddle::Function.new(
|
|
423
|
+
@lib['hyperion_io_uring_hotpath_release_buffer'],
|
|
424
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_SHORT], Fiddle::TYPE_VOID
|
|
425
|
+
)
|
|
426
|
+
@hotpath_force_unhealthy_fn = Fiddle::Function.new(
|
|
427
|
+
@lib['hyperion_io_uring_hotpath_force_unhealthy'],
|
|
428
|
+
[Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID
|
|
429
|
+
)
|
|
430
|
+
@hotpath_is_healthy_fn = Fiddle::Function.new(
|
|
431
|
+
@lib['hyperion_io_uring_hotpath_is_healthy'],
|
|
432
|
+
[Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT
|
|
433
|
+
)
|
|
434
|
+
# Plan #2 Task 2.3.4 — one-copy buffer extraction: copies `len`
|
|
435
|
+
# bytes from kernel buffer-ring slot `buf_id` into a
|
|
436
|
+
# caller-supplied output buffer. Returns byte count or negative errno.
|
|
437
|
+
@hotpath_copy_buffer_fn = Fiddle::Function.new(
|
|
438
|
+
@lib['hyperion_io_uring_hotpath_copy_buffer'],
|
|
439
|
+
[Fiddle::TYPE_VOIDP, # ptr
|
|
440
|
+
Fiddle::TYPE_SHORT, # buf_id (u16)
|
|
441
|
+
Fiddle::TYPE_INT, # len (u32)
|
|
442
|
+
Fiddle::TYPE_VOIDP, # out_ptr
|
|
443
|
+
Fiddle::TYPE_INT], # out_cap (u32)
|
|
444
|
+
Fiddle::TYPE_INT
|
|
445
|
+
)
|
|
252
446
|
@lib
|
|
253
447
|
rescue Fiddle::DLError, StandardError => e
|
|
254
448
|
warn "[hyperion] IOUring failed to load (#{e.class}: #{e.message}); falling back to epoll"
|
|
@@ -259,9 +453,16 @@ module Hyperion
|
|
|
259
453
|
def candidate_paths
|
|
260
454
|
gem_lib = File.expand_path('../hyperion_io_uring', __dir__)
|
|
261
455
|
ext_target = File.expand_path('../../ext/hyperion_io_uring/target/release', __dir__)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
456
|
+
# Prefer the platform-native extension first so Fiddle.dlopen doesn't
|
|
457
|
+
# accidentally pick up a cross-platform binary (e.g. a macOS .dylib
|
|
458
|
+
# rsync'd into a Linux lib dir) which causes an ArgumentError on read.
|
|
459
|
+
native, other = linux? ? %w[.so .dylib] : %w[.dylib .so]
|
|
460
|
+
[
|
|
461
|
+
File.join(gem_lib, "libhyperion_io_uring#{native}"),
|
|
462
|
+
File.join(ext_target, "libhyperion_io_uring#{native}"),
|
|
463
|
+
File.join(gem_lib, "libhyperion_io_uring#{other}"),
|
|
464
|
+
File.join(ext_target, "libhyperion_io_uring#{other}"),
|
|
465
|
+
]
|
|
265
466
|
end
|
|
266
467
|
|
|
267
468
|
# ---- FFI wrappers ----
|
|
@@ -282,6 +483,48 @@ module Hyperion
|
|
|
282
483
|
def ring_read(ptr, fd, buf, max, errno_buf)
|
|
283
484
|
@read_fn.call(ptr, fd, buf, max, errno_buf)
|
|
284
485
|
end
|
|
486
|
+
|
|
487
|
+
def hotpath_ring_new(qd, n_bufs, buf_size)
|
|
488
|
+
ptr = @hotpath_ring_new_fn.call(qd, n_bufs, buf_size)
|
|
489
|
+
ptr.null? ? nil : ptr
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def hotpath_ring_free(ptr); @hotpath_ring_free_fn.call(ptr); end
|
|
493
|
+
|
|
494
|
+
def hotpath_submit_accept(ptr, fd)
|
|
495
|
+
@hotpath_submit_accept_fn.call(ptr, fd)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def hotpath_submit_recv(ptr, fd)
|
|
499
|
+
@hotpath_submit_recv_fn.call(ptr, fd)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def hotpath_submit_send(ptr, fd, iov, n)
|
|
503
|
+
@hotpath_submit_send_fn.call(ptr, fd, iov, n)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def hotpath_wait(ptr, mc, t, out, cap)
|
|
507
|
+
@hotpath_wait_fn.call(ptr, mc, t, out, cap)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def hotpath_release_buf(ptr, buf_id)
|
|
511
|
+
@hotpath_release_buf_fn.call(ptr, buf_id)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def hotpath_force_unhealthy(ptr)
|
|
515
|
+
@hotpath_force_unhealthy_fn.call(ptr)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def hotpath_is_healthy(ptr)
|
|
519
|
+
@hotpath_is_healthy_fn.call(ptr)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Plan #2 Task 2.3.4 — copy `len` bytes from kernel buffer-ring
|
|
523
|
+
# slot `buf_id` into the caller-supplied output buffer. Returns
|
|
524
|
+
# the number of bytes written, or a negative errno on failure.
|
|
525
|
+
def hotpath_copy_buffer(ptr, buf_id, len, out_ptr, out_cap)
|
|
526
|
+
@hotpath_copy_buffer_fn.call(ptr, buf_id, len, out_ptr, out_cap)
|
|
527
|
+
end
|
|
285
528
|
end
|
|
286
529
|
|
|
287
530
|
# ---- Server-side helpers ----
|
|
@@ -313,5 +556,27 @@ module Hyperion
|
|
|
313
556
|
raise ArgumentError, "io_uring must be :off, :auto, or :on (got #{policy.inspect})"
|
|
314
557
|
end
|
|
315
558
|
end
|
|
559
|
+
|
|
560
|
+
# Plan #2 — resolve `:off | :auto | :on` for the hotpath gate.
|
|
561
|
+
# Mirrors `resolve_policy!` semantics: `:on` raises on unsupported,
|
|
562
|
+
# `:auto` quietly falls back, `:off` returns false.
|
|
563
|
+
def self.resolve_hotpath_policy!(policy)
|
|
564
|
+
case policy
|
|
565
|
+
when :off, nil, false
|
|
566
|
+
false
|
|
567
|
+
when :auto
|
|
568
|
+
hotpath_supported?
|
|
569
|
+
when :on, true
|
|
570
|
+
unless hotpath_supported?
|
|
571
|
+
raise Unsupported,
|
|
572
|
+
'io_uring hotpath required (io_uring_hotpath: :on) but unsupported on this host ' \
|
|
573
|
+
"(linux=#{linux?}, kernel_ok=#{kernel_supports_io_uring?}, " \
|
|
574
|
+
"hotpath_supported=#{hotpath_supported?})"
|
|
575
|
+
end
|
|
576
|
+
true
|
|
577
|
+
else
|
|
578
|
+
raise ArgumentError, "io_uring_hotpath must be :off, :auto, or :on (got #{policy.inspect})"
|
|
579
|
+
end
|
|
580
|
+
end
|
|
316
581
|
end
|
|
317
582
|
end
|