Skip to content

Reduce syscall volume, and/or TCP segment generation - #2541

Open
gsmecher wants to merge 2 commits into
yhirose:masterfrom
gsmecher:write_vectorization
Open

Reduce syscall volume, and/or TCP segment generation#2541
gsmecher wants to merge 2 commits into
yhirose:masterfrom
gsmecher:write_vectorization

Conversation

@gsmecher

@gsmecher gsmecher commented Aug 18, 2026

Copy link
Copy Markdown

Normally, httplib generates one TCP segment for headers and at least one more for body content. This is awful without TCP_NODELAY ("It's always TCP_NODELAY. Every damn time." [1]). Even with TCP_NODELAY, small responses can become unnecessarily segmented and throughput can suffer on deeply embedded hosts due to syscall overhead.

This patch series threads a vectorized write syscall (writev() on Linux/MacOS, something else under Win32) through httplib just enough to combine headers and body content into a single, zero-copy syscall under "happy path" conditions (chiefly, no ranged requests).

  • New Response::set_content() allows callers to provide a copy-free "borrowed view" into C arrays. The existing std::move-based set_content() call isn't useful for non-std::string arguments, and the existing Response::set_content(char*, size_t) call required copying. This new call requires some pointer-lifetime effort but allows copy-free responses to come from non-strings (e.g. mmap'd files); both static-mount and set_file_content() responses now use it.

  • Server::write_response_core uses writev() and "borrowed view" content to entirely combine most Responses into a single syscall. This avoids excess TCP segment generation (a good thing with or without NODELAY), and on resource-constrained systems, the avoided syscall overhead itself can be meaningful.

  • A new Response::clear_content() drops all pending content representations at the error-reset sites and uncaught handler exceptions, and every content setter clears a previously set borrowed view, so stale borrowed bytes can never trail a response whose headers no longer describe them.

The abidiff test fails (because the ABI did change, and I haven't modified anything to match). I'm uncertain how you handle ABI changes but happy to follow your lead.

Stream::writev() provides a default implementation that packs spans into
a bounded staging buffer, flushed in chunks. For streams without native
gather support (notably TLS, where every write() is at least one record
on the wire), small writev() calls still leave as a single write.

SocketStream::writev() wraps WSASend() on Win32 and sendmsg elsewhere,
allowing calling code to combine what would otherwise be multiple
syscalls into a single one.

Neither of these calls is used yet (see the following commit for
commentary.)
Normally, httplib generates one TCP segment for headers and at least one
more for body content. This is awful without TCP_NODELAY ("It's always
TCP_NODELAY. Every damn time." [1]). Even with TCP_NODELAY, small
responses can become unnecessarily segmented and throughput can suffer
on deeply embedded hosts due to syscall overhead.

This patch threads the previous commit's writev() support through
httplib just enough to combine headers and body content under "happy
path" conditions (chiefly, no ranged requests).

- New Response::set_content() allows callers to provide a copy-free
  "borrowed view" into C arrays. The existing std::move-based
  set_content() call isn't useful for non-std::string arguments, and the
  existing Response::set_content(char*, size_t) call required copying.
  This new call requires some pointer-lifetime effort but allows
  copy-free responses to come from non-strings (e.g. mmap'd files); both
  static-mount and set_file_content() responses now use it.

- Server::write_response_core uses writev() and "borrowed view" content
  to entirely combine most Responses into a single syscall. This avoids
  excess TCP segment generation (a good thing with or without NODELAY),
  and on resource-constrained systems, the avoided syscall overhead
  itself can be meaningful.

- A new Response::clear_content() drops all pending content
  representations at the error-reset sites (413/416/404 and uncaught
  handler exceptions), and every content setter clears a previously set
  borrowed view, so stale borrowed bytes can never trail a response
  whose headers no longer describe them.

[1]: https://brooker.co.za/blog/2024/05/09/nagle.html
@yhirose

yhirose commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR! The benchmark could not see this change at all: its only endpoint returns a 12-byte set_content() body, the one case where the response line, the headers and the body already share a single write(). So I added static-file, large-body and TLS workloads to it first (on master now, so please rebase to pick them up). Three things came out.

1. Large TLS responses lose a third to a half.

# after rebasing
./benchmark/ab.sh --base master --head <this branch> --path /static/large.bin --tls
workload 1 MiB 10 MiB
/large plain 1.129x 1.180x
/static/large.bin plain 1.038x (ns) 0.949x (ns)
/large TLS 0.576x 0.640x
/static/large.bin TLS 0.563x 0.534x

Five alternating rounds each; every bold row separates at p = 0.008, and /static/small.js gains 1.260x plain and 1.358x TLS, so the harness is not simply reporting noise.

SSLSocketStream does not override writev(), so every TLS response takes the Stream::writev() fallback, which re-fragments the body into CPPHTTPLIB_SEND_BUFSIZ pieces: 64 SSL_write() calls for 1 MiB and 640 for 10 MiB, where master issued one. The comment reasons that "large ones chunk at the same granularity TLS fragments records anyway", but a 16 KiB maximum record does not make 16 KiB write calls free. master handed the whole buffer to OpenSSL and let it emit the records internally.

Handing a span larger than the staging buffer straight to write(), instead of copying it through, recovered this for me: 1 MiB dynamic went to 1709 req/s against master's 1589. Worth noting the fallback is what every non-socket Stream uses, user-defined ones included, so this is not only about TLS.

2. A response can promise a body and send none.

TEST(BorrowedContentTest, ReplacingViewWithEmptyBodyClearsContentLength) {
  Server svr;

  const std::string data = "0123456789abcdefghij"; // 20 bytes
  svr.Get("/replaced", [&](const Request & /*req*/, Response &res) {
    res.set_content(data.data(), data.size(), "text/plain", nullptr);
    res.set_content("", "text/plain"); // drop it again
  });

  auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
  auto se = detail::scope_exit([&] {
    svr.stop();
    listen_thread.join();
  });
  svr.wait_until_ready();

  Client cli("localhost", PORT);
  cli.set_read_timeout(1, 0);

  auto res = cli.Get("/replaced");
  ASSERT_TRUE(res) << "Error: " << to_string(res.error());
  EXPECT_EQ("0", res->get_header_value("Content-Length"));
  EXPECT_EQ("", res->body);
}

On the branch this reports Error: Failed to read connection. content_length_ doubles as the view's length, and the copying set_content() overloads clear content_view_data_ without clearing it, so apply_ranges() believes the stale length while no content source is left to write the bytes. The response goes out with Content-Length: 20 and no body on an open keep-alive connection, and the client reads the next response as this one's body. master is correct here, and this is reachable without ever calling the new API since the PR moves handle_file_request() onto it.

inline void Response::set_content(const char *s, size_t n,
                                  const std::string &content_type) {
  body.assign(s, n);
  content_view_data_ = nullptr;
  content_length_ = 0;                   // add
  content_provider_ = nullptr;           // add
  is_chunked_content_provider_ = false;  // add

plus the same three lines in the std::string && overload.

3. The releaser is not always called.

TEST(BorrowedContentTest, ReplacingContentReleasesTheOldView) {
  Server svr;

  const std::string a = "AAAA";
  const std::string b = "BBBB";
  std::atomic<int> released_a{0};

  svr.Get("/twice", [&](const Request & /*req*/, Response &res) {
    res.set_content(a.data(), a.size(), "text/plain",
                    [&](bool) { released_a++; });
    res.set_content(b.data(), b.size(), "text/plain", nullptr);
  });

  auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); });
  auto se = detail::scope_exit([&] {
    svr.stop();
    listen_thread.join();
  });
  svr.wait_until_ready();

  Client cli("localhost", PORT);
  auto res = cli.Get("/twice");
  ASSERT_TRUE(res) << "Error: " << to_string(res.error());
  EXPECT_EQ("BBBB", res->body);
  EXPECT_EQ(1, released_a.load()); // the discarded view is still released
}

Setting content a second time overwrites the previous releaser instead of invoking it, so released_a stays 0. The library's own [mm](bool) {} releasers escape this only by luck: they hold the mapping in a captured shared_ptr, so destroying the discarded std::function unmaps it anyway. One that does its work in the body, like [p](bool) { free(p); }, has no such fallback.

  void clear_content() {
    if (content_provider_resource_releaser_) {       // add
      content_provider_resource_releaser_(false);    // add
      content_provider_resource_releaser_ = nullptr; // add
    }
    body.clear();

Nothing to do about abidiff, by the way. It failing just makes the next release a minor bump instead of a patch.

@gsmecher

Copy link
Copy Markdown
Author

Wow - thanks, that's a more detailed review than I expected, and benchmarking feels like you've done some of my homework for me. Thank you. I will rebase, take a good look, and report back.

Overall: do you think the juice will be worth the squeeze? I'm primarily optimizing for small embedded hosts, and if this PR feels like a distraction from your primary use case, please say so.

Thanks,
Graeme

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