Skip to content

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

Open
gsmecher wants to merge 1 commit into
yhirose:masterfrom
gsmecher:write_vectorization
Open

Reduce syscall volume, and/or TCP segment generation#2541
gsmecher wants to merge 1 commit 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.

@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

@gsmecher
gsmecher force-pushed the write_vectorization branch from c8afb41 to e508d80 Compare August 24, 2026 19:14
gsmecher added a commit to gsmecher/cpp-httplib that referenced this pull request Aug 24, 2026
Address yhirose's review of yhirose#2541.

Stream::writev():

- only flatten write calls into a local buffer when they are short.
  Otherwise, iterate over the individual buffers in the writev() and
  hand them one-by-one to a normal write(). The net result is a ~30%
  speedup over baseline on SSL, which is surprising - the unexpected gains
  come from an extraneous copy in the baseline code we dropped in the
  new code.

Response:

- the copying set_content() overloads now clear content_length_,
  content_provider_ and is_chunked_content_provider_, so replacing a
  borrowed view with an owned body can no longer emit a Content-Length
  describing bytes that are never sent.

- The bare releaser is replaced with ContentResource, a shared-ownership
  holder whose state destructor fires the releaser via RAII. This moves
  clean-up into the type system so we don't have to worry about corner
  cases. This also removes the need for explicit
  constructors/destructor on Resource.
@gsmecher

Copy link
Copy Markdown
Author

@yhirose - I have rebased to catch your changes to master, and pushed out a squashable commit to address your comments. If you prefer, I can squash this commit myself (leaving just the two logical commits you would want to merge.)

I've also reproduced your benchmarks and think the regressions are addressed now.

gsmecher added a commit to gsmecher/cpp-httplib that referenced this pull request Aug 24, 2026
Address yhirose's review of yhirose#2541.

Stream::writev():

- only flatten write calls into a local buffer when they are short.
  Otherwise, iterate over the individual buffers in the writev() and
  hand them one-by-one to a normal write(). The net result is a ~30%
  speedup over baseline on SSL, which is surprising - the unexpected gains
  come from an extraneous copy in the baseline code we dropped in the
  new code.

Response:

- the copying set_content() overloads now clear content_length_,
  content_provider_ and is_chunked_content_provider_, so replacing a
  borrowed view with an owned body can no longer emit a Content-Length
  describing bytes that are never sent.

- The bare releaser is replaced with ContentResource, a shared-ownership
  holder whose state destructor fires the releaser via RAII. This moves
  clean-up into the type system so we don't have to worry about corner
  cases. This also removes the need for explicit
  constructors/destructor on Resource.
@gsmecher
gsmecher force-pushed the write_vectorization branch 2 times, most recently from 4980747 to 4158693 Compare August 31, 2026 17:59
Normally, httplib generates one TCP segment for headers and at least one
more for body content. This performs badly without TCP_NODELAY ("It's
always TCP_NODELAY. Every damn time." [1]). With TCP_NODELAY, small
responses can become unnecessarily segmented and throughput can suffer
on deeply embedded hosts due to syscall overhead.

This patch adds a vectorized (gather) write to Stream and threads it
through httplib just enough to combine headers and body content into a
single, zero-copy syscall under "happy path" conditions (chiefly, no
ranged requests).

- SocketStream::writev() wraps sendmsg() (WSASend() under Win32),
  allowing multiple buffers to leave in a single syscall. Stream
  provides a default implementation for streams without native gather
  support (notably TLS, where every write() is at least one record on
  the wire): small totals are flattened into a staging buffer and leave
  as a single write(), and large ones are written buffer-by-buffer so
  malloc/memcpy overheads don't outweigh the syscall overheads we're
  trying to duck.

- A new Response::set_content() overload 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 copied.
  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).

- A new Response::clear_content() drops pending content at the
  error-reset sites (413/416/404 and 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.

- Releaser bookkeeping moves from ~Response into a shared
  detail::ContentResource that invokes the releaser exactly once, when
  the last Response referencing it falls out of scope. This lets
  clear_content() and the content setters safely drop or replace a
  releaser-bearing body, and gives Response its defaulted special member
  functions back.

[1]: https://brooker.co.za/blog/2024/05/09/nagle.html
@gsmecher
gsmecher force-pushed the write_vectorization branch from 4158693 to 6968237 Compare August 31, 2026 18:03
@gsmecher

Copy link
Copy Markdown
Author

(rebased off master and simplified to match recent changes.)

Repository owner deleted a comment from yuvarajayuvarajay276-wq Sep 1, 2026
Repository owner deleted a comment from yuvarajayuvarajay276-wq Sep 1, 2026
Repository owner deleted a comment from yuvarajayuvarajay276-wq Sep 1, 2026
Repository owner deleted a comment from yuvarajayuvarajay276-wq Sep 1, 2026
Repository owner deleted a comment from yuvarajayuvarajay276-wq Sep 1, 2026
Repository owner deleted a comment from yuvarajayuvarajay276-wq Sep 1, 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.

2 participants