Skip to content

api: reduce syscall overhead in client and server - #714

Open
rjarry wants to merge 6 commits into
DPDK:mainfrom
rjarry:api-syscall-optim
Open

api: reduce syscall overhead in client and server#714
rjarry wants to merge 6 commits into
DPDK:mainfrom
rjarry:api-syscall-optim

Conversation

@rjarry

@rjarry rjarry commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Reduce the number of syscalls per API transaction on both client and server sides.

On the server, use BEV_OPT_DEFER_CALLBACKS so that libevent accumulates header and payload writes in the evbuffer and flushes them in a single writev(). Remove the no-op bufferevent_flush() calls and the manual read_cb re-trigger which libevent already handles.

On the client, replace two separate send() calls with a single sendmsg() using an iovec, and add a BUFSIZ read-ahead buffer so that recv_all() can serve multiple small reads from one recv() syscall. This is particularly effective during stream iterations where many small messages arrive back-to-back.

A GR_PING request type and accompanying ping_perf benchmark tool are added to measure the resulting transaction rate.

Different approach from https://patches.dpdk.org/project/grout/patch/20260720224924.3299328-1-mb@smartsharesystems.com/

Cc: @MortenBroerup

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@rjarry
rjarry force-pushed the api-syscall-optim branch from d82f3d7 to c7c3ea5 Compare August 26, 2026 13:40
@MortenBroerup

MortenBroerup commented Aug 26, 2026 via email

Copy link
Copy Markdown
Contributor

The bufferevent_sock implementation of flush is a no-op, so remove
the bufferevent_flush() calls. The manual re-trigger of read_cb when
input data remains is also unnecessary since libevent will invoke
the callback as long as data is available in the input buffer.

Signed-off-by: Robin Jarry <rjarry@redhat.com>
@rjarry
rjarry force-pushed the api-syscall-optim branch from c7c3ea5 to f63b50a Compare August 26, 2026 14:31

@MortenBroerup MortenBroerup 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.

Deferred bufferevent callbacks look like good solution.
I have a concern about huge responses...
For reference, PHP has functions to enable/disable output buffering.
Is there any means to limit libevent buffering for huge responses? Otherwise, they may consume huge amounts of memory in the server (Grout), instead of streaming in chunks to the client.
I'm asking for either a configurable threshold in libevent, or a function to temporarily disable/enable deferring the callbacks, or a flush-like function that invokes the callback.

@rjarry
rjarry force-pushed the api-syscall-optim branch from f63b50a to 78ce613 Compare August 26, 2026 20:53
@rjarry

rjarry commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Deferred bufferevent callbacks look like good solution. I have a concern about huge responses... For reference, PHP has functions to enable/disable output buffering. Is there any means to limit libevent buffering for huge responses? Otherwise, they may consume huge amounts of memory in the server (Grout), instead of streaming in chunks to the client. I'm asking for either a configurable threshold in libevent, or a function to temporarily disable/enable deferring the callbacks, or a flush-like function that invokes the callback.

That's a good point. I have capped the amount of buffering before flushing to the socket.

// 64K allows buffering up to 16 * 4K evbuffer chains before flushing
#define HIGH_WATERMARK (1 << 16)

void api_send(struct api_ctx *ctx, uint32_t len, const void *payload) {
	...

	if (bufferevent_write(ctx->bev, &resp, sizeof(resp)) < 0)
		LOG(ERR, "pid=%d cannot write header", ctx->pid);
	if (bufferevent_write(ctx->bev, payload, len) < 0)
		LOG(ERR, "pid=%d cannot write payload", ctx->pid);

	// Force flush when buffer grows past HIGH_WATERMARK to avoid runaway memory use
	struct evbuffer *output = bufferevent_get_output(ctx->bev);
	if (evbuffer_get_length(output) >= HIGH_WATERMARK) {
		evutil_socket_t fd = bufferevent_getfd(ctx->bev);
		evbuffer_unfreeze(output, true);
		evbuffer_write_atmost(output, fd, -1);
		evbuffer_freeze(output, true);
	}
}

@rjarry
rjarry force-pushed the api-syscall-optim branch 2 times, most recently from fe047e5 to 93e7ba8 Compare August 26, 2026 21:44
@MortenBroerup

MortenBroerup commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Deferred bufferevent callbacks look like good solution. I have a concern about huge responses... For reference, PHP has functions to enable/disable output buffering. Is there any means to limit libevent buffering for huge responses? Otherwise, they may consume huge amounts of memory in the server (Grout), instead of streaming in chunks to the client. I'm asking for either a configurable threshold in libevent, or a function to temporarily disable/enable deferring the callbacks, or a flush-like function that invokes the callback.

That's a good point. I have capped the amount of buffering before flushing to the socket.

// 64K allows buffering up to 16 * 4K evbuffer chains before flushing
#define HIGH_WATERMARK (1 << 16)

void api_send(struct api_ctx *ctx, uint32_t len, const void *payload) {
	...

	if (bufferevent_write(ctx->bev, &resp, sizeof(resp)) < 0)
		LOG(ERR, "pid=%d cannot write header", ctx->pid);
	if (bufferevent_write(ctx->bev, payload, len) < 0)
		LOG(ERR, "pid=%d cannot write payload", ctx->pid);

	// Force flush when buffer grows past HIGH_WATERMARK to avoid runaway memory use
	struct evbuffer *output = bufferevent_get_output(ctx->bev);
	if (evbuffer_get_length(output) >= HIGH_WATERMARK) {
		evutil_socket_t fd = bufferevent_getfd(ctx->bev);
		evbuffer_unfreeze(output, true);
		evbuffer_write_atmost(output, fd, -1);
		evbuffer_freeze(output, true);
	}
}

I was wondering if HIGH_WATERMARK and BUFSIZ in API read-ahead should be similar? Maybe not, because they execute in two different applications. But - considering the value of HIGH_WATERMARK (64 KB)- maybe read-ahead should be higher than BUFSIZ (8 KB).
Should we use GR_API_MAX_MSG_LEN?
Regardless, I think HIGH_WATERMARK should be at least GR_API_MAX_MSG_LEN.
No good reasons for changing these; just my initial thoughts - gut feeling.

Comment thread main/api.c Outdated
if (evbuffer_get_length(output) >= HIGH_WATERMARK) {
evutil_socket_t fd = bufferevent_getfd(ctx->bev);
evbuffer_unfreeze(output, true);
evbuffer_write_atmost(output, fd, -1);

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.

When crossing the HIGH_WATERMARK, the flush calls deeply inside libevent, to replicate this:
https://github.com/libevent/libevent/blob/master/bufferevent_sock.c#L301
But the error handling is missing here.
Would it be possible to call the libevent write callback instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have double checked and we don't need this flag after all. All bufferevent_write() calls made synchronously (without yielding back to the event loop) are coalesced into a single EV_WRITE event. When the read callback returns (after writing any number of times), up to 512K bytes will be written in a single system call (using writev() + iovecs).

I have removed the commit that added BEV_OPT_DEFER_CALLBACKS entirely.

@MortenBroerup

MortenBroerup commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

I was wondering if HIGH_WATERMARK and BUFSIZ in API read-ahead should be similar? Maybe not, because they execute in two different applications. But - considering the value of HIGH_WATERMARK (64 KB)- maybe read-ahead should be higher than BUFSIZ (8 KB).
Should we use GR_API_MAX_MSG_LEN?

After further consideration: No, they serve different purposes. Let's keep them individual.

Regardless, I think HIGH_WATERMARK should be at least GR_API_MAX_MSG_LEN.

If we consider the HIGH_WATERMARK a safety guard against runaway buffering, it can be much higher, such as 2 MB. We don't want to trigger it unnecessarily.
(GR_API_MAX_MSG_LEN is 128 KB.)

No good reasons for changing these; just my initial thoughts - gut feeling.

rjarry and others added 5 commits August 28, 2026 00:33
Handle EAGAIN/EWOULDBLOCK in recv_all() and the sendmsg() loop so
that callers using non-blocking sockets do not get spurious errors.
When the error occurs mid-transfer, poll() and retry. When nothing
has been read yet, propagate the error so the caller can distinguish
"no data available" from a real failure.

Signed-off-by: Robin Jarry <rjarry@redhat.com>
Set O_NONBLOCK on the notification sockets after subscribing. When
gr_api_client_event_recv() returns EWOULDBLOCK, re-arm event_add_read()
using the socket fd.

After successfully reading one event, re-arm event_add_read() without
any file descriptor to schedule the read callback immediately.

Signed-off-by: Robin Jarry <rjarry@redhat.com>
Use sendmsg() with an iovec instead of two separate send() calls
for header and payload. This halves the number of syscalls per API
request on the client side.

Signed-off-by: Robin Jarry <rjarry@redhat.com>
Add a read-ahead buffer to the API client so that recv_all() can serve
multiple small reads from a single recv() syscall. Size this buffer so
it can receive one full-size message (very unlikely to exist).

When the remaining bytes to read fit in the buffer, recv() reads ahead
into it; subsequent calls drain the buffer without any syscall.

This is especially effective during stream iterations where many small
header+payload messages arrive back-to-back.

Signed-off-by: Morten Brørup <mb@smartsharesystems.com>
Signed-off-by: Robin Jarry <rjarry@redhat.com>
Add a ping request that accepts an optional payload and echoes it
back. The accompanying ping_perf tool sends a configurable number
of ping calls in a tight loop and reports the transaction rate,
useful for benchmarking API overhead.

Signed-off-by: Morten Brørup <mb@smartsharesystems.com>
Signed-off-by: Robin Jarry <rjarry@redhat.com>
@rjarry
rjarry force-pushed the api-syscall-optim branch from 93e7ba8 to 77a56f1 Compare August 27, 2026 22:40
@rjarry

rjarry commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Following up on what I saw in the iovec implementation of evbuffer write callbacks, they can deal with up to 512K bytes in a single writev() (128 * 4K chains).

So we could size the client side buffer accordingly. But that looks a bit ridiculous. I have gone with GR_API_MAX_MSG_SIZE which seems reasonable.

About runaway memory consumption concerns: the current implementation will already buffer everything in streaming responses before writing all at once. The only way to prevent infinite memory allocation with unbounded streams would be to allow pausing/resuming streaming responses.

This is something I had already attempted in #548 but it seemed too complicated and over engineered at the moment.

Comment thread api/gr_api_client_impl.h
continue;
}

if (remaining < sizeof(c->recv_buf)) {

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.

With recv_buf[sizeof(struct gr_api_response) + GR_API_MAX_MSG_LEN]], this branch will always be taken. (Except when receiving a max-size message.)
In other words: We could consider changing the algorithm from conditional read-ahead, to just always read via the recv_buf.

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.

2 participants