Skip to content

ring: the ring's descriptor is registered, so an enter skips the fget/fput of it - #391

Merged
MDA2AV merged 1 commit into
mainfrom
fix/98-registered-ring-fd
Oct 23, 2026
Merged

MDA2AV merged 1 commit into
mainfrom
fix/98-registered-ring-fd

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Fixes #98.

Every io_uring_enter looked up the ring's own descriptor and took a reference to it (fget), then dropped it again (fput), before doing any work. That happens once per loop pass, on the process's busiest syscall. IORING_REGISTER_RING_FDS (kernel 5.18) puts the ring in the calling thread's table and hands back a slot. An enter that passes that slot with IORING_ENTER_REGISTERED_RING reads the ring from the table instead. liburing does this for every ring it creates.

What changes:

  • Registration: Ring.Create registers the descriptor as its last step, and SubmitAndWait passes the slot and the flag.
  • Same thread: the slot lives in the creating thread's table. That restricts nothing here: only the thread that created a SINGLE_ISSUER | DEFER_TASKRUN ring can enter it at all.
  • Fallback: a thread has 16 slots. A ring created after they are all taken enters by descriptor, as before.
  • Dispose: frees the slot before closing the descriptor. The slot holds a reference to the ring, so a ring closed while still registered would stay alive, memlock charge included, until its thread exited.

Ring.Fd is unchanged, and the register calls keep using it.

Measured

Per enter, with a C probe on a ring set up like ioxide's, pinned to one P-core, 5M iterations, 3 alternating rounds each:

by descriptor by registered slot
empty GETEVENTS enter 71 ns 64 ns −7 ns
NOP round trip (submit 1, wait 1) 90 ns 82 ns −8 ns

The same holds with a second thread alive, as in any .NET process.

wrk on Tcp/Raw: flat, as it should be when one enter carries a batch of requests.

  • 2 reactors on two P-cores, wrk -t4 -c64 on other cores.
  • Every round runs each build back to back, in rotated order; a second worktree of main is the noise control.
  • Figures are the median of the per-round ratios.
run rounds req/s vs main CPU/req vs main control, main vs main
1 10 × 10 s −1.5% +1.2% −0.2% / +0.5%
2 12 × 10 s +0.8% −1.5% +0.6% / −0.1%

The two runs disagree in sign, and single rounds varied by up to ±10% on this box today.

Tests

New, in RegisteredRingTests (Unit):

  • ring: enters go through the registered slot, not the descriptor: a NOP round trip works, then the slot is emptied behind the ring's back and the next enter must fail with EBADF. An enter by descriptor would not notice.
  • ring: disposing a ring frees its registered slot: after Dispose, an enter on the old slot must answer EBADF.
  • ring: a ring created with every slot taken still works, through its descriptor: 17 rings on one thread. The first takes a slot; the 17th falls back to its descriptor and works.

Red-checked, one break per test:

enters by slot dispose frees the slot fallback
enters by descriptor (slot registered, unused) FAIL: got 1, not EBADF PASS PASS
Dispose leaves the slot registered PASS FAIL: the closed ring still answers PASS
no fallback without a free slot PASS PASS FAIL
this PR PASS PASS PASS

Docs: the ring setup list in architecture and the Ring section of dev-core.

All suites pass, every reactor in them on a registered ring: Unit 105, E2E 322, Chaos 80, Http 89, Tls 169 (+14 skipped), File 5, Pg 7 (+11 skipped), Redis (6 skipped, 1 pending). The Tls skips are the kTLS cases (no tls module loaded here), and the Pg and Redis ones need servers this box doesn't run.

It merges cleanly with #386, which also changes Ring.Create.

API: Native gains IORING_ENTER_REGISTERED_RING, IORING_REGISTER_RING_FDS, IORING_UNREGISTER_RING_FDS and io_uring_rsrc_update. Nothing is removed or changed.

…/fput of it

Every io_uring_enter looked the ring's descriptor up and took a reference to it, then dropped it,
before doing any work: once per loop pass, on the busiest syscall in the process.
IORING_REGISTER_RING_FDS (5.18) puts the ring in the calling thread's table and hands back a slot;
an enter that passes the slot with IORING_ENTER_REGISTERED_RING reads the ring from there instead.
liburing does this for every ring it creates.

Ring.Create registers the descriptor last, and SubmitAndWait passes the slot and the flag. The slot
is in the creating thread's table, which restricts nothing: only the thread that created a
SINGLE_ISSUER | DEFER_TASKRUN ring can enter it at all. A thread has 16 slots; a ring created
without one enters by descriptor as before. Dispose frees the slot before closing the descriptor -
the slot holds a reference to the ring, so a ring closed while registered would stay alive, memlock
charge included, until its thread exited. Ring.Fd and the register calls are unchanged.

Measured with a C probe on a ring set up like ioxide's, pinned to one P-core: an empty GETEVENTS
enter 71 -> 64 ns, a NOP round trip 90 -> 82 ns, the same with a second thread alive.
wrk Tcp/Raw, two runs of paired rounds against main: -1.5% and +0.8% req/s, with the main-vs-main
control at -0.2% and +0.6% - flat, as it should be when one enter carries a batch of requests.

Tests (Unit, RegisteredRingTests): an enter goes through the slot (emptied behind the ring's back,
the next enter fails with EBADF; by descriptor it would not notice); Dispose frees the slot (an
enter on it afterwards answers EBADF); a ring created with all 16 slots taken works through its
descriptor. Red-checked one break per test: entering by descriptor, Dispose leaving the slot, and
no fallback each fail exactly their own test.

Docs: the ring setup list in architecture and the Ring section of dev-core.
@MDA2AV
MDA2AV merged commit fe08288 into main Oct 23, 2026
1 of 2 checks passed
@MDA2AV MDA2AV mentioned this pull request Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

register the ring fd (IORING_REGISTER_RING_FDS) to skip per-enter fdget/fdput

1 participant