Skip to content

warp/recv: optimistic non-blocking recv before parking the thread - #1099

Open
seanparsons wants to merge 1 commit into
yesodweb:masterfrom
seanparsons:perf/optimistic-recv
Open

warp/recv: optimistic non-blocking recv before parking the thread#1099
seanparsons wants to merge 1 commit into
yesodweb:masterfrom
seanparsons:perf/optimistic-recv

Conversation

@seanparsons

@seanparsons seanparsons commented Jul 19, 2026

Copy link
Copy Markdown

Part of a series splitting #1090 into independently reviewable PRs; self-contained (recv + warp). The single largest win of the series.

makeGracefulRecv (on the hot path of every runSettings server, since ServerState is always set up) registers with the IO manager and parks in STM on every receive, waitReadSocketSTM creates an event-manager registration up front, the composed atomically blocks until readable, and only then is recv(2) called. Under load the next keep-alive request is nearly always already in the kernel buffer, so every request paid one epoll_ctl plus a futex sleep/wake pair for nothing. Syscall census before: ~1 epoll_ctl and ~28 futex calls per request, with recv essentially never returning EAGAIN.

Changes: recv gains receiveNoWait (direct non-blocking recv(2) on the already-SOCK_NONBLOCK fd; tryWithBufferPool keeps the pool intact on decline; EAGAIN and real errors both defer to the blocking path so exceptions keep their errno; Just "" is EOF; Windows always returns Nothing = unchanged behavior). makeGracefulRecv tries it first; the slow path is byte-for-byte the previous implementation. One edge: a request already in the kernel buffer is served even if shutdown began meanwhile restoring pre-graceful-shutdown behavior for bytes the client already sent. The Conc.yield after each response turns out to be load-bearing with this fast path (it lets the next request arrive; removing it costs ~17%), comment updated.

After: 186 epoll_ctl and 725 futex calls total across 198k requests; the per-request syscall profile is one recvfrom + one sendto. Measured: +36% on top of the rest of the series (52.8k → 71.7k req/s; stock baseline 35.7k), −N1 unpinned 67.6k → 91.5k. Also validated on a production Servant app: +57-80% on light endpoints, byte-identical responses. Spec suite passes.

Note: receiveNoWait uses its own FFI recv(2) because Network.Socket.recvBufNoWait isn't exported from network's public API; happy to switch if exporting it upstream is preferred.

The graceful-shutdown receive path (makeGracefulRecv) registered with
the IO manager and parked in STM on EVERY receive: waitReadSocketSTM
creates an event-manager registration up front, then the composed
atomically blocks until the socket is readable, and only then is
recv(2) called. Under load the next pipelined/keep-alive request is
almost always already in the kernel buffer, so every request paid one
epoll_ctl plus a futex sleep/wake pair for nothing. A syscall census
of a hello-world server showed ~1 epoll_ctl and ~28 futex calls per
request, with recv essentially never returning EAGAIN. Since
runSettings always sets up ServerState, every default-configured warp
server pays this.

Changes:

  * recv: new receiveNoWait, a direct non-blocking recv(2) on the
    already-non-blocking fd (one unsafe foreign call; the socket is
    SOCK_NONBLOCK so it returns immediately). The new
    tryWithBufferPool leaves the buffer pool intact when the filler
    declines. EAGAIN and real errors both map to Nothing, deferring to
    the blocking path so exceptions surface there with errno intact.
    Just "" is EOF. Windows: always Nothing (unchanged behavior).
  * warp: makeGracefulRecv tries receiveNoWait first and enters the
    STM/shutdown machinery only on would-block. The slow path is
    byte-for-byte the previous implementation. A request already in
    the kernel buffer is now served even if shutdown began meanwhile,
    which merely restores pre-graceful-shutdown behavior for bytes the
    client already sent.
  * The Conc.yield after each response is kept and its comment
    updated: measurements show it is load-bearing in combination with
    this fast path (it gives the next request time to arrive, raising
    the fast-path hit rate; removing it costs ~17% throughput).

Syscall census after: 186 epoll_ctl and 725 futex calls TOTAL across
198k requests (previously ~1 and ~28 per request); the per-request
profile is one recvfrom + one sendto. Measured standalone-equivalent
on the full optimization series: 52.8k -> 71.7k req/s (stock warp
baseline 35.7k); unpinned -N1: 67.6k -> 91.5k req/s. Also validated
on a production Servant app: +57-80% on light endpoints with
byte-identical responses. Spec suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HBrMGfWRxTpYeEB4UT8Kf

@kazu-yamamoto kazu-yamamoto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I support this change because it restores an optimization that was lost when transitioning to the event-poll model. The recv function in the network library is actually implemented this way: it first attempts a non-blocking c_recv and only invokes the I/O manager if it encounters EAGAIN.

@kazu-yamamoto

Copy link
Copy Markdown
Contributor

@Vlix Compared to other PRs, I think this one is clear and easy to merge.

@mkleczek

mkleczek commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

One edge: a request already in the kernel buffer is served even if shutdown began meanwhile restoring pre-graceful-shutdown behavior for bytes the client already sent.

So under load warp won't stop serving incoming requests? It looks to me it breaks graceful shutdowns - that was exactly the problem described in #853

@Vlix Vlix left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might break the graceful shutdown. We need to know the extent of this change before we can accept it.

mbs <- receiveNoWait sock pool
case mbs of
Just bs -> return bs
Nothing -> slowPath

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean that we might never check the shutting down state if the client is prompt with their bytes and we happen to only recv when there's bytes in the kernel buffer?

-- and produces 'Nothing'.
tryWithBufferPool
:: BufferPool -> (Buffer -> BufSize -> IO Int) -> IO (Maybe ByteString)
tryWithBufferPool (BufferPool l h ref) f = do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the duplication of code here, though we might have to.

This will also need tests like 'withBufferPool', though, if it IS duplicated.
And it will need tests either way, even if we refactor withBufferPool for this new functionality.

return $ fromIntegral n

foreign import ccall unsafe "recv"
c_recv :: CInt -> Ptr CSsize -> CSize -> CInt -> IO CSsize

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We basically want to use recvBufNoWait from Network.Socket.Buffer, but it's not exported.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If necessary, I can export it from network.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that in itself is a good idea. It's nice for the user to be able to decide to block or not block on a recv.

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.

4 participants