Skip to content

Commit 18693a5

Browse files
etrclaude
andcommitted
TASK-058 step 0: add bench_warm_path harness; capture baseline
Three-measurement bench isolates the per-request allocations targeted by TASK-058: (1) canonicalize: lookup_v2 on a canonical path -- targets canonicalize_lookup_path's per-call std::string allocation. (2) should_skip_auth (non-empty + empty list) -- targets the per- request normalize_path call. (3) serialize_allow_405 -- targets the rebuild-on-every-405 cost. No pass/fail ceilings: bench reports numbers; reviewers compare before/after manually. Sanitizer builds skip with exit 0. Baseline (--enable-debug build on Darwin arm64): canonicalize: median = 620 ns should_skip_auth_nonempty: median = 623 ns should_skip_auth_empty: median = 616 ns serialize_allow_405: median = 97 ns Wired into make bench via bench_targets in test/Makefile.am (not part of make check). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 60eba6f commit 18693a5

2 files changed

Lines changed: 291 additions & 1 deletion

File tree

test/Makefile.am

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ EXTRA_DIST = libhttpserver.supp \
544544
# 3. Add a `<name>_LDADD = ...` line if it needs more than the
545545
# default empty LDADD.
546546
# 4. Run `make bench` from the build directory.
547-
bench_targets = bench_sizeof_http_resource bench_get_headers bench_hook_overhead bench_route_lookup
547+
bench_targets = bench_sizeof_http_resource bench_get_headers bench_hook_overhead bench_route_lookup bench_warm_path
548548
EXTRA_PROGRAMS = $(bench_targets)
549549

550550
# bench_sizeof_http_resource: pure compile-time static_assert guard.
@@ -589,6 +589,15 @@ bench_hook_overhead_LDADD = $(LDADD) -lmicrohttpd
589589
bench_route_lookup_SOURCES = bench_route_lookup.cpp
590590
bench_route_lookup_LDADD = $(LDADD) -lmicrohttpd
591591

592+
# bench_warm_path (TASK-058): per-request allocation pass. Times
593+
# canonicalize_lookup_path, should_skip_auth (non-empty + empty list),
594+
# and serialize_allow_methods to verify the TASK-058 refactors land
595+
# without regressing the warm GET path. Defines HTTPSERVER_COMPILATION
596+
# so the bench can reach webserver_test_access, the same friend
597+
# pattern used by bench_route_lookup.
598+
bench_warm_path_SOURCES = bench_warm_path.cpp
599+
bench_warm_path_LDADD = $(LDADD) -lmicrohttpd
600+
592601
bench: $(bench_targets)
593602
@for p in $(bench_targets); do \
594603
echo "=== Running bench: $$p ==="; \

test/bench_warm_path.cpp

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/*
2+
This file is part of libhttpserver
3+
Copyright (C) 2011-2026 Sebastiano Merlino
4+
5+
This library is free software; you can redistribute it and/or
6+
modify it under the terms of the GNU Lesser General Public
7+
License as published by the Free Software Foundation; either
8+
version 2.1 of the License, or (at your option) any later version.
9+
10+
This library is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
Lesser General Public License for more details.
14+
15+
You should have received a copy of the GNU Lesser General Public
16+
License along with this library; if not, write to the Free Software
17+
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
18+
USA
19+
*/
20+
// TASK-058 -- Warm-path allocation pass benchmark.
21+
//
22+
// Three measurements isolate the per-request allocations that TASK-058
23+
// targets:
24+
// (1) canonicalize: lookup_v2() on a canonical path. Pre-TASK-058 this
25+
// allocated a std::string in canonicalize_lookup_path on every
26+
// call; after step 1 the happy path returns a string_view into
27+
// the caller's argument and allocates nothing.
28+
// (2) should_skip_auth (non-empty list): drives the per-request
29+
// normalize_path call when the skip-paths list is non-empty.
30+
// After step 2 the list is pre-normalized at construction so the
31+
// per-call cost drops to just the request-side normalize and a
32+
// linear scan over (already-canonical) entries.
33+
// (3) should_skip_auth (empty list): pre-TASK-058 still normalized
34+
// the request path even when no skip paths were configured.
35+
// After step 2 the empty-list early-out short-circuits before
36+
// the normalize call.
37+
// (4) serialize_allow_405: the cost of building the Allow: header
38+
// value for a 405 response. Pre-TASK-058 this rebuilds the
39+
// string on every 405; after step 3 a per-resource cache returns
40+
// the previously-computed std::string by reference.
41+
//
42+
// Wired into `make bench` via bench_targets in test/Makefile.am; not
43+
// part of `make check`. Sanitizer builds skip with exit 0 so the
44+
// bench stays green on sanitizer hosts (same convention as
45+
// bench_route_lookup). No pass/fail ceilings: the bench reports
46+
// numbers; reviewers compare before/after manually.
47+
48+
#define HTTPSERVER_COMPILATION 1 // unlock webserver_test_access
49+
50+
#include <algorithm>
51+
#include <chrono>
52+
#include <cstdio>
53+
#include <cstdlib>
54+
#include <memory>
55+
#include <string>
56+
#include <vector>
57+
58+
#include "httpserver/create_webserver.hpp"
59+
#include "httpserver/http_resource.hpp"
60+
#include "httpserver/http_response.hpp"
61+
#include "httpserver/http_utils.hpp"
62+
#include "httpserver/webserver.hpp"
63+
#include "httpserver/detail/webserver_impl.hpp"
64+
65+
namespace hs = httpserver;
66+
67+
// Defeat dead-store elimination on the lookup_result.
68+
template <typename T>
69+
[[gnu::always_inline]] inline void do_not_optimize(T const& value) {
70+
#if defined(__GNUC__) || defined(__clang__)
71+
asm volatile("" : : "r,m"(&value) : "memory");
72+
#else
73+
volatile const void* sink = static_cast<const void*>(&value);
74+
(void)sink;
75+
#endif
76+
}
77+
78+
static constexpr bool kSanitizerBuild =
79+
#if defined(__SANITIZE_ADDRESS__) \
80+
|| defined(__SANITIZE_THREAD__) \
81+
|| defined(__SANITIZE_MEMORY__) \
82+
|| defined(__SANITIZE_HWADDRESS__)
83+
true
84+
#elif defined(__has_feature)
85+
# if __has_feature(address_sanitizer) \
86+
|| __has_feature(thread_sanitizer) \
87+
|| __has_feature(memory_sanitizer) \
88+
|| __has_feature(undefined_behavior_sanitizer)
89+
true
90+
# else
91+
false
92+
# endif
93+
#else
94+
false
95+
#endif
96+
; // NOLINT(whitespace/semicolon)
97+
98+
namespace {
99+
100+
class noop_resource : public hs::http_resource {
101+
public:
102+
hs::http_response render_get(const hs::http_request&) override {
103+
return hs::http_response::string("ok");
104+
}
105+
};
106+
107+
double sort_and_median(std::vector<double>& v) {
108+
std::sort(v.begin(), v.end());
109+
return v[v.size() / 2];
110+
}
111+
112+
double p99_of_sorted(std::vector<double>& v) {
113+
const std::size_t idx = (v.size() * 99) / 100;
114+
return v[std::min(idx, v.size() - 1)];
115+
}
116+
117+
// Measure a no-arg lambda's median ns/call over OUTER rounds of
118+
// INNER iterations each. Prints a one-line summary and returns the
119+
// median.
120+
template <typename F>
121+
double measure_median_ns(const char* label, F op,
122+
std::size_t outer, std::size_t inner) {
123+
using clock = std::chrono::steady_clock;
124+
std::vector<double> samples_ns;
125+
samples_ns.reserve(outer);
126+
127+
// Warmup: prime instruction cache + branch predictor.
128+
for (std::size_t i = 0; i < 10'000; ++i) {
129+
op();
130+
}
131+
132+
for (std::size_t r = 0; r < outer; ++r) {
133+
const auto t0 = clock::now();
134+
for (std::size_t i = 0; i < inner; ++i) {
135+
op();
136+
}
137+
const auto t1 = clock::now();
138+
const double ns_per_call =
139+
std::chrono::duration<double, std::nano>(t1 - t0).count() / inner;
140+
samples_ns.push_back(ns_per_call);
141+
}
142+
143+
const double min_ns =
144+
*std::min_element(samples_ns.begin(), samples_ns.end());
145+
const double max_ns =
146+
*std::max_element(samples_ns.begin(), samples_ns.end());
147+
const double median = sort_and_median(samples_ns);
148+
const double p99 = p99_of_sorted(samples_ns);
149+
std::printf(" %s: median=%.3fns p99=%.3fns (min=%.3fns max=%.3fns)\n",
150+
label, median, p99, min_ns, max_ns);
151+
return median;
152+
}
153+
154+
// Build a webserver with auth_handler_paths configured so the
155+
// auth-skip path is exercised. We never start the daemon (no MHD,
156+
// no socket).
157+
std::unique_ptr<hs::webserver> make_bench_webserver(
158+
std::vector<std::string> skip_paths) {
159+
// A no-op auth_handler so the auth surface is engaged.
160+
auto auth = [](const hs::http_request&)
161+
-> std::optional<hs::http_response> { return std::nullopt; };
162+
auto ws = std::make_unique<hs::webserver>(
163+
hs::create_webserver(8080)
164+
.start_method(hs::http::http_utils::INTERNAL_SELECT)
165+
.auth_handler(auth)
166+
.auth_skip_paths(std::move(skip_paths)));
167+
ws->register_path("/api/v1/users/me", std::make_shared<noop_resource>());
168+
// Resource with restricted method set so 405 path exercises
169+
// serialize_allow_methods. Only GET is allowed.
170+
auto restricted = std::make_shared<noop_resource>();
171+
restricted->disallow_all();
172+
restricted->set_allowing(hs::http_method::get, true);
173+
ws->register_path("/api/v1/restricted", restricted);
174+
return ws;
175+
}
176+
177+
} // namespace
178+
179+
int main() {
180+
if constexpr (kSanitizerBuild) {
181+
std::printf("bench_warm_path: skipped (sanitizer build "
182+
"would distort ns/call)\n");
183+
return 0;
184+
}
185+
186+
// Project convention: 11 outer rounds, 1M inner iterations
187+
// (TASK-058 acceptance criterion). serialize_allow_405 is more
188+
// expensive than the other measurements, so 100K inner is enough
189+
// to keep the wall time bounded.
190+
constexpr std::size_t OUTER = 11;
191+
constexpr std::size_t INNER = 1'000'000;
192+
constexpr std::size_t INNER_405 = 100'000;
193+
194+
// ----- (1) canonicalize: lookup_v2 on a canonical path. -----
195+
{
196+
auto ws = make_bench_webserver({"/public", "/health"});
197+
auto* impl = hs::webserver_test_access::impl(*ws);
198+
static const std::string kPath("/api/v1/users/me");
199+
// Warm the cache: subsequent lookups hit the cache tier, so
200+
// canonicalize_lookup_path is what dominates the visible work.
201+
{
202+
auto warm = impl->lookup_v2(hs::http_method::get, kPath);
203+
do_not_optimize(warm);
204+
}
205+
std::printf("bench_warm_path (1): canonicalize "
206+
"(/api/v1/users/me cache-hit)\n");
207+
measure_median_ns(
208+
"canonicalize",
209+
[&]() {
210+
auto r = impl->lookup_v2(hs::http_method::get, kPath);
211+
do_not_optimize(r);
212+
},
213+
OUTER, INNER);
214+
}
215+
216+
// ----- (2) should_skip_auth on a non-empty list. -----
217+
{
218+
auto ws = make_bench_webserver({"/public", "/health"});
219+
auto* impl = hs::webserver_test_access::impl(*ws);
220+
static const std::string kPath("/api/v1/users/me");
221+
std::printf("bench_warm_path (2): should_skip_auth "
222+
"(non-empty skip list)\n");
223+
measure_median_ns(
224+
"should_skip_auth_nonempty",
225+
[&]() {
226+
bool r = impl->should_skip_auth(kPath);
227+
do_not_optimize(r);
228+
},
229+
OUTER, INNER);
230+
}
231+
232+
// ----- (3) should_skip_auth on an empty list (the common case
233+
// for servers with no auth_handler configured -- but the bench
234+
// still installs an auth_handler so the dispatch surface engages
235+
// it). This is the production-typical case for servers that
236+
// either don't configure skip paths at all, or configure
237+
// auth_skip_paths({}) explicitly. -----
238+
{
239+
auto ws = make_bench_webserver({});
240+
auto* impl = hs::webserver_test_access::impl(*ws);
241+
static const std::string kPath("/api/v1/users/me");
242+
std::printf("bench_warm_path (3): should_skip_auth "
243+
"(empty skip list)\n");
244+
measure_median_ns(
245+
"should_skip_auth_empty",
246+
[&]() {
247+
bool r = impl->should_skip_auth(kPath);
248+
do_not_optimize(r);
249+
},
250+
OUTER, INNER);
251+
}
252+
253+
// ----- (4) serialize_allow_405: cost of building the Allow:
254+
// header value on a 405. Drive serialize_allow_methods directly
255+
// to isolate the format cost without spinning up MHD. -----
256+
{
257+
auto ws = make_bench_webserver({});
258+
auto* impl = hs::webserver_test_access::impl(*ws);
259+
// Build a fresh resource with a non-trivial mask.
260+
noop_resource r;
261+
r.disallow_all();
262+
r.set_allowing(hs::http_method::get, true);
263+
r.set_allowing(hs::http_method::head, true);
264+
r.set_allowing(hs::http_method::post, true);
265+
std::printf("bench_warm_path (4): serialize_allow_405 "
266+
"(GET, HEAD, POST mask)\n");
267+
measure_median_ns(
268+
"serialize_allow_405",
269+
[&]() {
270+
std::string s = impl->serialize_allow_methods(
271+
r.get_allowed_methods());
272+
do_not_optimize(s);
273+
},
274+
OUTER, INNER_405);
275+
}
276+
277+
std::printf("\nbench_warm_path: no pass/fail ceiling -- numbers "
278+
"reported above are the baseline; rerun after each "
279+
"TASK-058 step and compare medians manually.\n");
280+
return 0;
281+
}

0 commit comments

Comments
 (0)