From 572b5a3250c290b9fd4c1aaeebb16885521d5a93 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Fri, 11 Sep 2026 20:22:20 -0700 Subject: [PATCH 01/10] benchmark: measure Regex time and allocations There was no way to answer "is this faster" or "does this allocate more" about a change to the regex wrapper except by writing a throwaway program each time. The corpus is what the tree actually matches: a remap path rule, a host allowlist, an extension test, and the crash-guard rule from #5762. It times compiling, copying, a boolean match, a match with captures both with a fresh matches object and a reused one, matching through the shared context and through a caller supplied one, a twenty pattern set scan, and the long subject that drives a match onto the interpreter. The allocation half counts calls to the system allocator across each operation. PCRE2 makes every allocation for a compile or a match through the callbacks this wrapper installs, and those reach the system allocator, so the count covers what the wrapper caused. A match under the just-in-time engine should not reach it at all; the interpreter allocates a frames vector and does not. The counters are wired up on Linux, where defining the allocator symbols in the executable is enough to interpose them, and report themselves unavailable elsewhere rather than printing zero. Numbers are reported, not asserted. Block sizes differ between PCRE2 releases, so an assertion here would fail on a version whose match data does not fit the inline buffer, which is a fact worth printing rather than a test worth failing. --- tools/benchmark/CMakeLists.txt | 8 + tools/benchmark/benchmark_Regex.cc | 512 +++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 tools/benchmark/benchmark_Regex.cc diff --git a/tools/benchmark/CMakeLists.txt b/tools/benchmark/CMakeLists.txt index 1456824fb37..fb1b2d4c2d5 100644 --- a/tools/benchmark/CMakeLists.txt +++ b/tools/benchmark/CMakeLists.txt @@ -57,3 +57,11 @@ target_include_directories(benchmark_HuffmanDecode PRIVATE ${CMAKE_SOURCE_DIR}/l add_executable(benchmark_ascii_tolower benchmark_ascii_tolower.cc) target_link_libraries(benchmark_ascii_tolower PRIVATE Catch2::Catch2WithMain ts::tscore) + +add_executable(benchmark_Regex benchmark_Regex.cc) +target_link_libraries(benchmark_Regex PRIVATE Catch2::Catch2WithMain ts::tsutil) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + # The allocation counters interpose the system allocator, which needs the real + # symbols from the next object in the search order. + target_link_libraries(benchmark_Regex PRIVATE ${CMAKE_DL_LIBS}) +endif() diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc new file mode 100644 index 00000000000..cf63ef26727 --- /dev/null +++ b/tools/benchmark/benchmark_Regex.cc @@ -0,0 +1,512 @@ +/** @file + + Benchmarks for the tsutil Regex wrapper: time per operation and the number and size + of heap allocations each operation makes. + + The allocation half matters as much as the timing half. PCRE2 routes every allocation + it makes for a compile or a match through the callbacks the wrapper installs, and those + call the system allocator, so counting calls to malloc across a region counts exactly + what the wrapper caused. Under the just-in-time engine a match should reach the system + allocator zero times; the interpreter allocates a frames vector and does not. + + Interposing malloc is only wired up on Linux, where defining these symbols in the + executable is enough. Elsewhere the counters stay at zero and the report says so, so a + run on another platform still gives timings without quietly reporting zero allocations + as a result. + + @section license License + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#define CATCH_CONFIG_ENABLE_BENCHMARKING +#include +#include + +#include "tsutil/Regex.h" + +// --------------------------------------------------------------------------- +// Allocation counting +// --------------------------------------------------------------------------- + +namespace +{ +struct AllocStats { + unsigned long calls = 0; + unsigned long bytes = 0; +}; + +// Counting is per thread so a benchmark that spawns threads does not race the counters. +// These benchmarks are single threaded; the qualifier is here so the numbers stay honest +// if one is added later. +thread_local AllocStats alloc_stats; +thread_local bool alloc_counting = false; + +class CountAllocations +{ +public: + CountAllocations() + { + alloc_stats = AllocStats{}; + alloc_counting = true; + } + ~CountAllocations() { alloc_counting = false; } + + AllocStats + stats() const + { + return alloc_stats; + } +}; + +#if defined(__linux__) +constexpr bool ALLOC_COUNTING_AVAILABLE = true; +#else +constexpr bool ALLOC_COUNTING_AVAILABLE = false; +#endif + +} // namespace + +#if defined(__linux__) +#include + +// Interpose the system allocator. Defining these in the executable takes precedence over +// libc for every caller in the process, which is what makes the count cover PCRE2's own +// allocations as well as the wrapper's. +namespace +{ +using malloc_fn = void *(*)(size_t); +using free_fn = void (*)(void *); +using calloc_fn = void *(*)(size_t, size_t); +using realloc_fn = void *(*)(void *, size_t); + +malloc_fn real_malloc = nullptr; +free_fn real_free = nullptr; +calloc_fn real_calloc = nullptr; +realloc_fn real_realloc = nullptr; + +// dlsym() itself can allocate while the real pointers are still being resolved. Hand +// those few allocations out of a static buffer rather than recursing. +alignas(std::max_align_t) char bootstrap_buffer[16384]; +size_t bootstrap_used = 0; +bool resolving = false; + +bool +from_bootstrap(void *p) +{ + return p >= static_cast(bootstrap_buffer) && p < static_cast(bootstrap_buffer + sizeof(bootstrap_buffer)); +} + +void * +bootstrap_alloc(size_t size) +{ + size_t const aligned = (size + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1); + if (bootstrap_used + aligned > sizeof(bootstrap_buffer)) { + return nullptr; + } + void *p = bootstrap_buffer + bootstrap_used; + bootstrap_used += aligned; + return p; +} + +void +resolve_real_allocators() +{ + if (real_malloc != nullptr || resolving) { + return; + } + resolving = true; + real_malloc = reinterpret_cast(dlsym(RTLD_NEXT, "malloc")); + real_free = reinterpret_cast(dlsym(RTLD_NEXT, "free")); + real_calloc = reinterpret_cast(dlsym(RTLD_NEXT, "calloc")); + real_realloc = reinterpret_cast(dlsym(RTLD_NEXT, "realloc")); + resolving = false; +} + +void +record(size_t size) +{ + if (alloc_counting) { + ++alloc_stats.calls; + alloc_stats.bytes += size; + } +} +} // namespace + +extern "C" void * +malloc(size_t size) +{ + if (real_malloc == nullptr) { + resolve_real_allocators(); + if (real_malloc == nullptr) { + return bootstrap_alloc(size); + } + } + record(size); + return real_malloc(size); +} + +extern "C" void +free(void *p) +{ + if (p == nullptr || from_bootstrap(p)) { + return; + } + if (real_free == nullptr) { + resolve_real_allocators(); + } + real_free(p); +} + +extern "C" void * +calloc(size_t n, size_t size) +{ + if (real_calloc == nullptr) { + resolve_real_allocators(); + if (real_calloc == nullptr) { + void *p = bootstrap_alloc(n * size); + if (p != nullptr) { + memset(p, 0, n * size); + } + return p; + } + } + record(n * size); + return real_calloc(n, size); +} + +extern "C" void * +realloc(void *p, size_t size) +{ + if (real_realloc == nullptr) { + resolve_real_allocators(); + } + record(size); + return real_realloc(p, size); +} +#endif // __linux__ + +// --------------------------------------------------------------------------- +// Corpus +// +// Patterns and subjects taken from what the tree actually matches: remap rules, a host +// allowlist, an extension test, and the crash-guard rule from #5762. +// --------------------------------------------------------------------------- + +namespace +{ +char const *const PATTERN_PATH = R"(^/([^/]+)/([^/]+)/(.*)$)"; +char const *const PATTERN_HOST = R"(^(?:[a-z0-9-]+\.)*example\.com$)"; +char const *const PATTERN_EXTENSION = R"(\.(jpg|jpeg|png|gif|css|js)$)"; +char const *const PATTERN_QUERY = R"(^/alpha/bravo/[?]((?!action=(newsfeed|calendar|contacts|notepad)).)*$)"; + +std::string_view const SUBJECT_PATH = "/images/2026/summer/header.jpg"; +std::string_view const SUBJECT_HOST = "cdn.edge.example.com"; +std::string_view const SUBJECT_EXTENSION = "/images/2026/summer/header.jpg"; +std::string_view const SUBJECT_MISS = "/no/match/here/at/all"; + +// A set of host patterns, the shape a rule list has when a caller scans one in order. +std::vector +host_patterns(int count) +{ + std::vector patterns; + patterns.reserve(count); + for (int i = 0; i < count; ++i) { + patterns.emplace_back("^(?:[a-z0-9-]+\\.)*host" + std::to_string(i) + "\\.example\\.com$"); + } + return patterns; +} + +void +report_allocations(char const *label, AllocStats const &stats, unsigned long operations) +{ + if constexpr (!ALLOC_COUNTING_AVAILABLE) { + printf(" %-44s allocation counting not available on this platform\n", label); + return; + } + printf(" %-44s %8.2f allocations/op %10.1f bytes/op (%lu ops)\n", label, + static_cast(stats.calls) / static_cast(operations), + static_cast(stats.bytes) / static_cast(operations), operations); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Timing +// --------------------------------------------------------------------------- + +TEST_CASE("Regex compile", "[bench][regex]") +{ + BENCHMARK("compile path pattern") + { + Regex re; + re.compile(PATTERN_PATH); + return re.empty(); + }; + + BENCHMARK("compile host pattern") + { + Regex re; + re.compile(PATTERN_HOST); + return re.empty(); + }; + + BENCHMARK("compile extension pattern") + { + Regex re; + re.compile(PATTERN_EXTENSION); + return re.empty(); + }; + + Regex source; + source.compile(PATTERN_PATH); + BENCHMARK("copy a compiled pattern") + { + Regex copy(source); + return copy.empty(); + }; +} + +TEST_CASE("Regex match", "[bench][regex]") +{ + Regex path; + path.compile(PATTERN_PATH); + Regex host; + host.compile(PATTERN_HOST); + Regex extension; + extension.compile(PATTERN_EXTENSION); + + BENCHMARK("bool exec, hit") + { + return path.exec(SUBJECT_PATH); + }; + + BENCHMARK("bool exec, miss") + { + return host.exec(SUBJECT_MISS); + }; + + BENCHMARK("exec with captures, hit") + { + RegexMatches matches; + return path.exec(SUBJECT_PATH, matches); + }; + + BENCHMARK("exec with captures, reused matches object") + { + static RegexMatches matches; + return path.exec(SUBJECT_PATH, matches); + }; + + BENCHMARK("exec with captures, miss") + { + RegexMatches matches; + return path.exec(SUBJECT_MISS, matches); + }; + + BENCHMARK("bool exec, extension pattern") + { + return extension.exec(SUBJECT_EXTENSION); + }; + + BENCHMARK("bool exec, host pattern") + { + return host.exec(SUBJECT_HOST); + }; +} + +TEST_CASE("Regex match with a caller supplied context", "[bench][regex]") +{ + Regex path; + path.compile(PATTERN_PATH); + RegexMatchContext context; + + BENCHMARK("exec through the shared context") + { + RegexMatches matches; + return path.exec(SUBJECT_PATH, matches, 0, nullptr); + }; + + BENCHMARK("exec through a caller supplied context") + { + RegexMatches matches; + return path.exec(SUBJECT_PATH, matches, 0, &context); + }; +} + +TEST_CASE("DFA set match", "[bench][regex]") +{ + auto const patterns = host_patterns(20); + std::vector raw; + raw.reserve(patterns.size()); + for (auto const &p : patterns) { + raw.push_back(p.c_str()); + } + + DFA dfa; + dfa.compile(raw.data(), static_cast(raw.size()), RE_UNANCHORED); + + std::string const first{"cdn.host0.example.com"}; + std::string const last{"cdn.host19.example.com"}; + std::string const none{"cdn.nothing.example.org"}; + + BENCHMARK("DFA match, first pattern") + { + return dfa.match(first); + }; + + BENCHMARK("DFA match, last of 20") + { + return dfa.match(last); + }; + + BENCHMARK("DFA match, no match over 20") + { + return dfa.match(none); + }; +} + +TEST_CASE("Regex interpreter path", "[bench][regex]") +{ + // A subject long enough to exhaust the JIT stack for this pattern, so the operation + // measured is the error return rather than a match. This is the shape the crash guard + // from #5762 covers, and it is the one place a match is expected to cost real time. + Regex query; + query.compile(PATTERN_QUERY); + + std::string subject{"/alpha/bravo/?"}; + subject.append(64 * 1024, 'x'); + + BENCHMARK("exec that exhausts the JIT stack, 64KiB subject") + { + RegexMatches matches; + return query.exec(subject, matches); + }; +} + +// --------------------------------------------------------------------------- +// Allocations +// +// Reported rather than asserted: the point of the run is the comparison between two +// implementations, and a hard assertion here would fail on a PCRE2 whose block sizes +// differ from the ones the inline buffer was sized against. +// --------------------------------------------------------------------------- + +TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") +{ + constexpr unsigned long OPS = 10000; + + Regex path; + path.compile(PATTERN_PATH); + Regex host; + host.compile(PATTERN_HOST); + + printf("\nAllocations (%s)\n", ALLOC_COUNTING_AVAILABLE ? "counted through an interposed system allocator" : "unavailable"); + + { + // Warm anything that allocates once per thread before counting. + RegexMatches warm; + path.exec(SUBJECT_PATH, warm); + } + + { + CountAllocations counter; + for (unsigned long i = 0; i < OPS; ++i) { + volatile bool r = path.exec(SUBJECT_PATH); + (void)r; + } + report_allocations("bool exec, hit", counter.stats(), OPS); + } + + { + CountAllocations counter; + for (unsigned long i = 0; i < OPS; ++i) { + volatile bool r = host.exec(SUBJECT_MISS); + (void)r; + } + report_allocations("bool exec, miss", counter.stats(), OPS); + } + + { + CountAllocations counter; + for (unsigned long i = 0; i < OPS; ++i) { + RegexMatches matches; + volatile int r = path.exec(SUBJECT_PATH, matches); + (void)r; + } + report_allocations("exec with captures, fresh matches", counter.stats(), OPS); + } + + { + RegexMatches matches; + CountAllocations counter; + for (unsigned long i = 0; i < OPS; ++i) { + volatile int r = path.exec(SUBJECT_PATH, matches); + (void)r; + } + report_allocations("exec with captures, reused matches", counter.stats(), OPS); + } + + { + constexpr unsigned long COMPILES = 1000; + CountAllocations counter; + for (unsigned long i = 0; i < COMPILES; ++i) { + Regex re; + re.compile(PATTERN_PATH); + } + report_allocations("compile a path pattern", counter.stats(), COMPILES); + } + + { + constexpr unsigned long COPIES = 1000; + CountAllocations counter; + for (unsigned long i = 0; i < COPIES; ++i) { + Regex copy(path); + volatile bool r = copy.empty(); + (void)r; + } + report_allocations("copy a compiled pattern", counter.stats(), COPIES); + } + + { + // The interpreter allocates its backtracking frames through the match data's + // allocator; the JIT engine does not. This is the operation that separates them. + Regex query; + query.compile(PATTERN_QUERY); + std::string subject{"/alpha/bravo/?"}; + subject.append(64 * 1024, 'x'); + + constexpr unsigned long EXECS = 200; + CountAllocations counter; + for (unsigned long i = 0; i < EXECS; ++i) { + RegexMatches matches; + volatile int r = query.exec(subject, matches); + (void)r; + } + report_allocations("exec that exhausts the JIT stack", counter.stats(), EXECS); + } + + printf("\n"); + CHECK(true); +} From f70992eb6e83fc58db3073746e6693b9701d783b Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 12:45:16 -0700 Subject: [PATCH 02/10] benchmark: do not let a wrapper see a half-resolved allocator table resolve_real_allocators() assigned real_malloc first and returned early on it, so a nested allocation during the remaining dlsym lookups re-entered a wrapper, found the early return satisfied, and called through a still-null real_free. Resolve all four into locals and publish them together with real_malloc last, which is the pointer the early return tests. Each wrapper keeps testing its own pointer, so none can reach a half-resolved table: free leaks rather than calling null while resolving, and realloc moves a bootstrap block with malloc and memcpy instead of handing a pointer the system allocator never issued to the real realloc. --- tools/benchmark/benchmark_Regex.cc | 53 ++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index cf63ef26727..c14548b861c 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -130,18 +130,29 @@ bootstrap_alloc(size_t size) return p; } +// Resolve all four into locals and publish them together, with real_malloc last. dlsym() +// may allocate or free while these lookups are in progress, which re-enters the wrappers +// below; they test their own pointer and fall back to the bootstrap path while it is still +// null, so no wrapper can reach a half-resolved table. void resolve_real_allocators() { if (real_malloc != nullptr || resolving) { return; } - resolving = true; - real_malloc = reinterpret_cast(dlsym(RTLD_NEXT, "malloc")); - real_free = reinterpret_cast(dlsym(RTLD_NEXT, "free")); - real_calloc = reinterpret_cast(dlsym(RTLD_NEXT, "calloc")); - real_realloc = reinterpret_cast(dlsym(RTLD_NEXT, "realloc")); - resolving = false; + resolving = true; + + auto *m = reinterpret_cast(dlsym(RTLD_NEXT, "malloc")); + auto *f = reinterpret_cast(dlsym(RTLD_NEXT, "free")); + auto *c = reinterpret_cast(dlsym(RTLD_NEXT, "calloc")); + auto *r = reinterpret_cast(dlsym(RTLD_NEXT, "realloc")); + + real_free = f; + real_calloc = c; + real_realloc = r; + real_malloc = m; // published last: this is the pointer the early return above tests + + resolving = false; } void @@ -175,6 +186,11 @@ free(void *p) } if (real_free == nullptr) { resolve_real_allocators(); + if (real_free == nullptr) { + // Still resolving, so there is nothing to free through. Leaking the few blocks the + // loader turns over during startup is better than calling through a null pointer. + return; + } } real_free(p); } @@ -202,6 +218,31 @@ realloc(void *p, size_t size) if (real_realloc == nullptr) { resolve_real_allocators(); } + + // A block handed out by bootstrap_alloc() is not one the system allocator knows, so it + // cannot be passed to the real realloc. Move it instead: the bootstrap sizes are tiny and + // this happens only while the loader is still resolving. + if (from_bootstrap(p)) { + if (real_malloc == nullptr) { + return bootstrap_alloc(size); + } + record(size); + void *moved = real_malloc(size); + if (moved != nullptr) { + // The original size is not recorded, so copy the smaller of the request and what is + // left of the bootstrap buffer from p. Both are small and the buffer is still mapped. + size_t const available = sizeof(bootstrap_buffer) - static_cast(static_cast(p) - bootstrap_buffer); + memcpy(moved, p, size < available ? size : available); + } + return moved; + } + + if (real_realloc == nullptr) { + // Still resolving and this is not a bootstrap block, so there is nothing safe to do + // with it other than hand back a fresh one. + return bootstrap_alloc(size); + } + record(size); return real_realloc(p, size); } From da6b2fb34040452e0d2f6628fd0544e1455a6db0 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 13:35:22 -0700 Subject: [PATCH 03/10] benchmark: declare the interposed allocators noexcept and size the bootstrap blocks Three corrections to the allocator interposition. glibc declares malloc, free, calloc and realloc with __THROW, which is noexcept in C++, so defining them without it is an incompatible redeclaration. The interposer is inside a Linux guard and glibc is what it is compiled against, so the definitions now carry noexcept to match. A bootstrap block now begins with a header holding its size, one max_align_t wide so the pointer handed back keeps malloc's alignment. Without it a realloc of a bootstrap block could not know how much to carry over. realloc of a bootstrap block copies the old contents on both paths. It previously returned a fresh block with nothing in it when the real allocator was not resolved yet, and guessed at the length when it was. The loader reallocs while resolving symbols, so returning uninitialised storage there fails the lookup in a way that is very hard to read back to this function. Also finish the sentence in the file comment describing which engine allocates. --- tools/benchmark/benchmark_Regex.cc | 58 +++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index c14548b861c..629e2e43ea0 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -7,7 +7,9 @@ it makes for a compile or a match through the callbacks the wrapper installs, and those call the system allocator, so counting calls to malloc across a region counts exactly what the wrapper caused. Under the just-in-time engine a match should reach the system - allocator zero times; the interpreter allocates a frames vector and does not. + allocator zero times, because the match data comes out of the caller's own buffer. The + interpreter is the exception: it allocates a backtracking frames vector through the same + allocator, so a match that runs interpreted does show up in the count. Interposing malloc is only wired up on Linux, where defining these symbols in the executable is enough. Elsewhere the counters stay at zero and the report says so, so a @@ -108,6 +110,13 @@ realloc_fn real_realloc = nullptr; // dlsym() itself can allocate while the real pointers are still being resolved. Hand // those few allocations out of a static buffer rather than recursing. +// +// Each block is preceded by a header holding its size, so a realloc of one can copy the +// old contents rather than silently returning uninitialised storage. The header is one +// max_align_t wide so the pointer handed back keeps the alignment malloc promises. +constexpr size_t BOOTSTRAP_HEADER = alignof(std::max_align_t); +static_assert(BOOTSTRAP_HEADER >= sizeof(size_t), "the bootstrap header must hold a size"); + alignas(std::max_align_t) char bootstrap_buffer[16384]; size_t bootstrap_used = 0; bool resolving = false; @@ -121,13 +130,22 @@ from_bootstrap(void *p) void * bootstrap_alloc(size_t size) { - size_t const aligned = (size + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1); - if (bootstrap_used + aligned > sizeof(bootstrap_buffer)) { + size_t const payload = (size + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1); + if (bootstrap_used + BOOTSTRAP_HEADER + payload > sizeof(bootstrap_buffer)) { return nullptr; } - void *p = bootstrap_buffer + bootstrap_used; - bootstrap_used += aligned; - return p; + char *block = bootstrap_buffer + bootstrap_used; + memcpy(block, &size, sizeof(size)); + bootstrap_used += BOOTSTRAP_HEADER + payload; + return block + BOOTSTRAP_HEADER; +} + +size_t +bootstrap_size(void *p) +{ + size_t size = 0; + memcpy(&size, static_cast(p) - BOOTSTRAP_HEADER, sizeof(size)); + return size; } // Resolve all four into locals and publish them together, with real_malloc last. dlsym() @@ -166,7 +184,7 @@ record(size_t size) } // namespace extern "C" void * -malloc(size_t size) +malloc(size_t size) noexcept { if (real_malloc == nullptr) { resolve_real_allocators(); @@ -179,7 +197,7 @@ malloc(size_t size) } extern "C" void -free(void *p) +free(void *p) noexcept { if (p == nullptr || from_bootstrap(p)) { return; @@ -196,7 +214,7 @@ free(void *p) } extern "C" void * -calloc(size_t n, size_t size) +calloc(size_t n, size_t size) noexcept { if (real_calloc == nullptr) { resolve_real_allocators(); @@ -213,26 +231,32 @@ calloc(size_t n, size_t size) } extern "C" void * -realloc(void *p, size_t size) +realloc(void *p, size_t size) noexcept { if (real_realloc == nullptr) { resolve_real_allocators(); } // A block handed out by bootstrap_alloc() is not one the system allocator knows, so it - // cannot be passed to the real realloc. Move it instead: the bootstrap sizes are tiny and - // this happens only while the loader is still resolving. + // cannot be passed to the real realloc. Move it instead, carrying the old contents over: + // the loader reallocs while resolving symbols, and handing back uninitialised storage + // there makes the lookup fail in a way that is very hard to read. if (from_bootstrap(p)) { + size_t const old = bootstrap_size(p); + size_t const copy = size < old ? size : old; + if (real_malloc == nullptr) { - return bootstrap_alloc(size); + void *moved = bootstrap_alloc(size); + if (moved != nullptr) { + memcpy(moved, p, copy); + } + return moved; } + record(size); void *moved = real_malloc(size); if (moved != nullptr) { - // The original size is not recorded, so copy the smaller of the request and what is - // left of the bootstrap buffer from p. Both are small and the buffer is still mapped. - size_t const available = sizeof(bootstrap_buffer) - static_cast(static_cast(p) - bootstrap_buffer); - memcpy(moved, p, size < available ? size : available); + memcpy(moved, p, copy); } return moved; } From 6f1309bd16d1b5bab905b794e49827824e7d4371 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 13:50:47 -0700 Subject: [PATCH 04/10] benchmark: name the JIT stack case honestly and stop timing a static's guard Three corrections. The case called "Regex interpreter path" does not measure the interpreter. compile() hands the pattern to the JIT, and what the long subject reaches is the JIT's own stack bound, so the name made filtered output misleading. It is now "Regex match that exhausts the JIT stack", with a note that reaching the interpreter needs a pattern the JIT refuses outright, which costs tens of seconds per match and has no business in a timed suite. The reused-matches case held its RegexMatches in a function-local static, and Catch2 runs that body once per timed iteration, so every iteration paid for the thread-safe initialisation guard. Build it outside the timed body instead, which is what the case was meant to measure in the first place. std::max_align_t is declared in , which this file relied on getting transitively. Include it. --- tools/benchmark/benchmark_Regex.cc | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index 629e2e43ea0..0f88193eb3d 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -35,6 +35,7 @@ limitations under the License. */ +#include #include #include #include @@ -378,10 +379,12 @@ TEST_CASE("Regex match", "[bench][regex]") return path.exec(SUBJECT_PATH, matches); }; + // Built once, outside the timed body: a function-local static would put its + // initialisation guard inside every iteration and charge the measurement for it. + RegexMatches reused; BENCHMARK("exec with captures, reused matches object") { - static RegexMatches matches; - return path.exec(SUBJECT_PATH, matches); + return path.exec(SUBJECT_PATH, reused); }; BENCHMARK("exec with captures, miss") @@ -452,11 +455,16 @@ TEST_CASE("DFA set match", "[bench][regex]") }; } -TEST_CASE("Regex interpreter path", "[bench][regex]") +TEST_CASE("Regex match that exhausts the JIT stack", "[bench][regex]") { // A subject long enough to exhaust the JIT stack for this pattern, so the operation // measured is the error return rather than a match. This is the shape the crash guard // from #5762 covers, and it is the one place a match is expected to cost real time. + // + // Note this is not the interpreter: compile() gives the pattern to the JIT, and what the + // long subject reaches is the JIT's own stack bound. Driving a match onto the interpreter + // needs a pattern the JIT refuses outright, which starts around 36KB of pattern text and + // costs tens of seconds per match, so it has no place in a timed suite. Regex query; query.compile(PATTERN_QUERY); From b4de4a8552563e30f9bd435296813971e1de9b15 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 13:59:26 -0700 Subject: [PATCH 05/10] benchmark: bound the bootstrap allocator, and ask whether the JIT is there Three fixes. bootstrap_alloc() rounded the request up to alignment before testing it against the remaining buffer, so a size near the top of the range wrapped to a small payload, passed the capacity test, and returned storage far smaller than asked for. These wrappers stand in for malloc for every library in the process while dlsym resolves, so that block would have corrupted somebody else's startup. Check the request against what is left first, then round. calloc got the same treatment for its n * size, which could wrap and then memset a huge region through a small block. The two cases that drive a subject into the JIT stack bound now check that this PCRE2 actually produced machine code for the pattern. Only libpcre2-8 is required, and a build without the just-in-time compiler, or one that refuses this pattern, would run the same subject to completion on the interpreter: much slower, and measuring something else under the same label. The timed case skips and the allocation case says so rather than reporting a number that means something different. Also correct the comment on the allocation case, which described it as separating the interpreter from the JIT. It does not: it shows that giving up against the JIT stack costs no allocations either. --- tools/benchmark/benchmark_Regex.cc | 86 ++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index 0f88193eb3d..bec222d2862 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -36,6 +36,7 @@ */ #include +#include #include #include #include @@ -49,6 +50,9 @@ #include "tsutil/Regex.h" +#define PCRE2_CODE_UNIT_WIDTH 8 +#include + // --------------------------------------------------------------------------- // Allocation counting // --------------------------------------------------------------------------- @@ -131,8 +135,17 @@ from_bootstrap(void *p) void * bootstrap_alloc(size_t size) { + // Check the request against what is left before rounding it up. Rounding first would let + // a huge size wrap to a small payload, pass the capacity test, and hand back storage far + // smaller than asked for. These wrappers stand in for malloc for every library in the + // process while dlsym resolves, so that block would corrupt somebody else's startup. + size_t const remaining = sizeof(bootstrap_buffer) - bootstrap_used; + if (remaining <= BOOTSTRAP_HEADER || size > remaining - BOOTSTRAP_HEADER) { + return nullptr; + } + size_t const payload = (size + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1); - if (bootstrap_used + BOOTSTRAP_HEADER + payload > sizeof(bootstrap_buffer)) { + if (payload > remaining - BOOTSTRAP_HEADER) { return nullptr; } char *block = bootstrap_buffer + bootstrap_used; @@ -220,9 +233,15 @@ calloc(size_t n, size_t size) noexcept if (real_calloc == nullptr) { resolve_real_allocators(); if (real_calloc == nullptr) { - void *p = bootstrap_alloc(n * size); + // n * size can wrap, which would ask the bootstrap buffer for a small block and then + // memset a huge one. Refuse rather than compute it. + if (size != 0 && n > SIZE_MAX / size) { + return nullptr; + } + size_t const total = n * size; + void *p = bootstrap_alloc(total); if (p != nullptr) { - memset(p, 0, n * size); + memset(p, 0, total); } return p; } @@ -304,6 +323,27 @@ host_patterns(int count) return patterns; } +// Whether this PCRE2 produced machine code for a pattern. The project requires only +// libpcre2-8, and a build can be configured without the just-in-time compiler or refuse an +// individual pattern, in which case a subject sized to exhaust the JIT stack instead runs +// to completion on the interpreter. That measures something else entirely, and far more +// slowly, so the cases that depend on the JIT ask first. +bool +pattern_has_jit(char const *pattern) +{ + int errnum = 0; + PCRE2_SIZE erroffset = 0; + pcre2_code *code = pcre2_compile(reinterpret_cast(pattern), PCRE2_ZERO_TERMINATED, 0, &errnum, &erroffset, nullptr); + if (code == nullptr) { + return false; + } + pcre2_jit_compile(code, PCRE2_JIT_COMPLETE); + size_t jit_size = 0; + pcre2_pattern_info(code, PCRE2_INFO_JITSIZE, &jit_size); + pcre2_code_free(code); + return jit_size > 0; +} + void report_allocations(char const *label, AllocStats const &stats, unsigned long operations) { @@ -465,6 +505,10 @@ TEST_CASE("Regex match that exhausts the JIT stack", "[bench][regex]") // long subject reaches is the JIT's own stack bound. Driving a match onto the interpreter // needs a pattern the JIT refuses outright, which starts around 36KB of pattern text and // costs tens of seconds per match, so it has no place in a timed suite. + if (!pattern_has_jit(PATTERN_QUERY)) { + SKIP("PCRE2 has no JIT for this pattern, so there is no JIT stack to exhaust"); + } + Regex query; query.compile(PATTERN_QUERY); @@ -563,21 +607,29 @@ TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") } { - // The interpreter allocates its backtracking frames through the match data's - // allocator; the JIT engine does not. This is the operation that separates them. - Regex query; - query.compile(PATTERN_QUERY); - std::string subject{"/alpha/bravo/?"}; - subject.append(64 * 1024, 'x'); - - constexpr unsigned long EXECS = 200; - CountAllocations counter; - for (unsigned long i = 0; i < EXECS; ++i) { - RegexMatches matches; - volatile int r = query.exec(subject, matches); - (void)r; + // The JIT stack limit path: the subject is long enough that the match gives up against + // the per thread JIT stack rather than completing. It is here to show that giving up + // costs no allocations either, not to measure the interpreter, which this pattern never + // reaches on a build that has a JIT. Without a JIT there is no such bound, and the same + // subject would run to completion on the interpreter and allocate its frames vector, + // which is a different measurement wearing the same label. + if (pattern_has_jit(PATTERN_QUERY)) { + Regex query; + query.compile(PATTERN_QUERY); + std::string subject{"/alpha/bravo/?"}; + subject.append(64 * 1024, 'x'); + + constexpr unsigned long EXECS = 200; + CountAllocations counter; + for (unsigned long i = 0; i < EXECS; ++i) { + RegexMatches matches; + volatile int r = query.exec(subject, matches); + (void)r; + } + report_allocations("exec that exhausts the JIT stack", counter.stats(), EXECS); + } else { + printf(" %-44s skipped: this PCRE2 has no JIT for the pattern\n", "exec that exhausts the JIT stack"); } - report_allocations("exec that exhausts the JIT stack", counter.stats(), EXECS); } printf("\n"); From 09886e99dc60f82572bec94bf80651d1cc8347ca Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 14:09:59 -0700 Subject: [PATCH 06/10] benchmark: count the DFA cases, probe the JIT stack bound, fail realloc honestly Three fixes, one of which was a claim this benchmark could not back. The published results carried allocation figures for the two DFA scans, and nothing in the allocation section ever built or ran a DFA. The numbers happened to be right, zero either way, but they were not reproducible from this file. Count them, so the row means something. The two cases that depend on the subject reaching the just-in-time engine's stack bound asked whether PCRE2 had produced machine code, which does not establish it. A release whose JIT uses the stack differently would simply complete the match, and both cases would then measure an ordinary match under the stack-exhaustion label. Run the match once and look for the stack limit error instead, and build the subject in one place so the probe and the measurement cannot drift apart. realloc of a pointer that came from neither the bootstrap buffer nor a real allocator, while resolution is still in progress, returned a fresh bootstrap block. That is worse than failing: the caller takes uninitialised storage for its moved data and the original goes unfreed. Return null, which is a documented realloc outcome and leaves the caller's pointer valid. --- tools/benchmark/benchmark_Regex.cc | 95 +++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index bec222d2862..defa070c51b 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -282,9 +282,12 @@ realloc(void *p, size_t size) noexcept } if (real_realloc == nullptr) { - // Still resolving and this is not a bootstrap block, so there is nothing safe to do - // with it other than hand back a fresh one. - return bootstrap_alloc(size); + // Still resolving, and this block came from neither the bootstrap buffer nor a real + // allocator this wrapper can reach. Returning a fresh block would be worse than + // failing: the caller would take uninitialised storage for its moved data while the + // original went unfreed. Reporting failure is a documented realloc outcome and leaves + // the caller's pointer valid. + return nullptr; } record(size); @@ -323,25 +326,31 @@ host_patterns(int count) return patterns; } -// Whether this PCRE2 produced machine code for a pattern. The project requires only -// libpcre2-8, and a build can be configured without the just-in-time compiler or refuse an -// individual pattern, in which case a subject sized to exhaust the JIT stack instead runs -// to completion on the interpreter. That measures something else entirely, and far more -// slowly, so the cases that depend on the JIT ask first. +// Whether this pattern and subject really do reach the just-in-time engine's stack bound +// here. Asking whether PCRE2 produced machine code is not enough: only libpcre2-8 is +// required, a build can be configured without the JIT or refuse this pattern, and a release +// whose JIT uses the stack differently may simply complete the match. In any of those cases +// the subject runs to completion instead, which is a different and much slower measurement +// wearing the same label. Run it once and look at what actually comes back. bool -pattern_has_jit(char const *pattern) +exhausts_jit_stack(char const *pattern, std::string const &subject) { - int errnum = 0; - PCRE2_SIZE erroffset = 0; - pcre2_code *code = pcre2_compile(reinterpret_cast(pattern), PCRE2_ZERO_TERMINATED, 0, &errnum, &erroffset, nullptr); - if (code == nullptr) { + Regex re; + if (!re.compile(pattern)) { return false; } - pcre2_jit_compile(code, PCRE2_JIT_COMPLETE); - size_t jit_size = 0; - pcre2_pattern_info(code, PCRE2_INFO_JITSIZE, &jit_size); - pcre2_code_free(code); - return jit_size > 0; + RegexMatches matches; + return re.exec(subject, matches) == PCRE2_ERROR_JIT_STACKLIMIT; +} + +// The subject the two JIT stack cases use, built once so the probe and the measurement +// cannot drift apart. +std::string +jit_stack_subject() +{ + std::string subject{"/alpha/bravo/?"}; + subject.append(64 * 1024, 'x'); + return subject; } void @@ -505,16 +514,14 @@ TEST_CASE("Regex match that exhausts the JIT stack", "[bench][regex]") // long subject reaches is the JIT's own stack bound. Driving a match onto the interpreter // needs a pattern the JIT refuses outright, which starts around 36KB of pattern text and // costs tens of seconds per match, so it has no place in a timed suite. - if (!pattern_has_jit(PATTERN_QUERY)) { - SKIP("PCRE2 has no JIT for this pattern, so there is no JIT stack to exhaust"); + std::string const subject = jit_stack_subject(); + if (!exhausts_jit_stack(PATTERN_QUERY, subject)) { + SKIP("this PCRE2 does not reach the JIT stack bound for this pattern and subject"); } Regex query; query.compile(PATTERN_QUERY); - std::string subject{"/alpha/bravo/?"}; - subject.append(64 * 1024, 'x'); - BENCHMARK("exec that exhausts the JIT stack, 64KiB subject") { RegexMatches matches; @@ -585,6 +592,41 @@ TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") report_allocations("exec with captures, reused matches", counter.stats(), OPS); } + { + // The set scan, both ends of it. DFA holds a vector of Regex and tries them in order, + // and each attempt builds a RegexMatches internally, so this is where a per attempt + // allocation would show up if one existed. + auto const patterns = host_patterns(20); + std::vector raw; + raw.reserve(patterns.size()); + for (auto const &pattern : patterns) { + raw.push_back(pattern.c_str()); + } + DFA dfa; + dfa.compile(raw.data(), static_cast(raw.size()), RE_UNANCHORED); + + std::string const first{"cdn.host0.example.com"}; + std::string const last{"cdn.host19.example.com"}; + + { + CountAllocations counter; + for (unsigned long i = 0; i < OPS; ++i) { + volatile int r = dfa.match(first); + (void)r; + } + report_allocations("DFA match, first of 20", counter.stats(), OPS); + } + + { + CountAllocations counter; + for (unsigned long i = 0; i < OPS; ++i) { + volatile int r = dfa.match(last); + (void)r; + } + report_allocations("DFA match, last of 20", counter.stats(), OPS); + } + } + { constexpr unsigned long COMPILES = 1000; CountAllocations counter; @@ -613,11 +655,10 @@ TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") // reaches on a build that has a JIT. Without a JIT there is no such bound, and the same // subject would run to completion on the interpreter and allocate its frames vector, // which is a different measurement wearing the same label. - if (pattern_has_jit(PATTERN_QUERY)) { + std::string const subject = jit_stack_subject(); + if (exhausts_jit_stack(PATTERN_QUERY, subject)) { Regex query; query.compile(PATTERN_QUERY); - std::string subject{"/alpha/bravo/?"}; - subject.append(64 * 1024, 'x'); constexpr unsigned long EXECS = 200; CountAllocations counter; @@ -628,7 +669,7 @@ TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") } report_allocations("exec that exhausts the JIT stack", counter.stats(), EXECS); } else { - printf(" %-44s skipped: this PCRE2 has no JIT for the pattern\n", "exec that exhausts the JIT stack"); + printf(" %-44s skipped: this PCRE2 does not reach the JIT stack bound here\n", "exec that exhausts the JIT stack"); } } From 93515146ece3a31326669fb0c0207ad4c2edc131 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 14:17:56 -0700 Subject: [PATCH 07/10] benchmark: treat realloc(nullptr, size) as malloc(size) realloc is defined to behave as malloc when the pointer is null, and a caller is entitled to use it that way. The wrapper checked the pointer against the bootstrap buffer, found a null pointer was not from there, and fell through to the unresolved-pointer path, which reports failure. While the resolver was still running that turned a legitimate allocation into an unexpected failure for whoever made it, which during dlsym is the loader. Handle the null pointer first, by routing it through the interposed malloc. --- tools/benchmark/benchmark_Regex.cc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index defa070c51b..05cfd2d33ec 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -253,6 +253,14 @@ calloc(size_t n, size_t size) noexcept extern "C" void * realloc(void *p, size_t size) noexcept { + // realloc(nullptr, size) is defined to behave as malloc(size), and a caller is entitled + // to use it that way. Route it through the wrapper above rather than letting it reach the + // unresolved-pointer path below, which would turn a legitimate allocation into a failure + // while the resolver is still running. + if (p == nullptr) { + return malloc(size); + } + if (real_realloc == nullptr) { resolve_real_allocators(); } From 58dcc8857f3b28da5f7ccb8248450addb577e731 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 14:28:27 -0700 Subject: [PATCH 08/10] benchmark: give the miss case a subject that actually misses "exec with captures, miss" ran the path pattern against "/no/match/here/at/all", which the path pattern matches: a leading slash, two slash free components, and "here/at/all" as the remainder, returning four. The case has been timing a hit under the name of a miss, and it shows in the number, 15.7 ns against the 23.7 ns it reported before. Give it a subject the pattern cannot satisfy and say in the corpus why the two miss subjects differ. The host pattern's miss was a real miss and is unchanged. from_bootstrap() compared an arbitrary allocator pointer against an element of the bootstrap buffer with relational operators, which C++ leaves without a defined ordering for unrelated objects. Compare the addresses instead. A wrong answer there is not harmless: a false positive makes free() leak the block and makes realloc() read a header that was never written. The JIT stack probe ran the long subject before it could return false, so on a build without machine code for the pattern it paid the entire interpreter cost to discover there was no JIT stack to exhaust. Ask PCRE2_INFO_JITSIZE first, which is cheap and answers that case, and only then confirm the bound is reached. Define _GNU_SOURCE for the target. g++ and clang++ already define it for C++ on Linux, which is why this has been building, but the dependency on RTLD_NEXT should not rest on that. --- tools/benchmark/CMakeLists.txt | 5 ++++- tools/benchmark/benchmark_Regex.cc | 35 +++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/tools/benchmark/CMakeLists.txt b/tools/benchmark/CMakeLists.txt index fb1b2d4c2d5..9d16e32639f 100644 --- a/tools/benchmark/CMakeLists.txt +++ b/tools/benchmark/CMakeLists.txt @@ -62,6 +62,9 @@ add_executable(benchmark_Regex benchmark_Regex.cc) target_link_libraries(benchmark_Regex PRIVATE Catch2::Catch2WithMain ts::tsutil) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") # The allocation counters interpose the system allocator, which needs the real - # symbols from the next object in the search order. + # symbols from the next object in the search order. RTLD_NEXT is a GNU extension; + # g++ and clang++ already define _GNU_SOURCE for C++ on Linux, but say so here + # rather than relying on it. target_link_libraries(benchmark_Regex PRIVATE ${CMAKE_DL_LIBS}) + target_compile_definitions(benchmark_Regex PRIVATE _GNU_SOURCE) endif() diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index 05cfd2d33ec..ffbfd7724e7 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -126,10 +126,15 @@ alignas(std::max_align_t) char bootstrap_buffer[16384]; size_t bootstrap_used = 0; bool resolving = false; +// Compare addresses rather than pointers. Relational comparison of pointers into unrelated +// objects has no defined ordering in C++, and a wrong answer here is not harmless: a false +// positive makes free() leak the block and makes realloc() read a header that is not there. bool from_bootstrap(void *p) { - return p >= static_cast(bootstrap_buffer) && p < static_cast(bootstrap_buffer + sizeof(bootstrap_buffer)); + auto const address = reinterpret_cast(p); + auto const begin = reinterpret_cast(bootstrap_buffer); + return address >= begin && address < begin + sizeof(bootstrap_buffer); } void * @@ -320,7 +325,12 @@ char const *const PATTERN_QUERY = R"(^/alpha/bravo/[?]((?!action=(newsfeed|c std::string_view const SUBJECT_PATH = "/images/2026/summer/header.jpg"; std::string_view const SUBJECT_HOST = "cdn.edge.example.com"; std::string_view const SUBJECT_EXTENSION = "/images/2026/summer/header.jpg"; -std::string_view const SUBJECT_MISS = "/no/match/here/at/all"; +// A miss for the host pattern: it is a path, not a host. +std::string_view const SUBJECT_MISS = "/no/match/here/at/all"; +// A miss for the path pattern, which needs a leading slash and three slash separated +// components. "/no/match/here/at/all" is not one: it satisfies the pattern with a remainder +// of "here/at/all", so using it here would time a hit under the name of a miss. +std::string_view const SUBJECT_PATH_MISS = "not-a-path-at-all"; // A set of host patterns, the shape a rule list has when a caller scans one in order. std::vector @@ -343,6 +353,25 @@ host_patterns(int count) bool exhausts_jit_stack(char const *pattern, std::string const &subject) { + // Ask the cheap question first. Without machine code for this pattern there is no JIT + // stack to exhaust, and running the long subject to find that out would put the whole + // interpreter cost on a probe whose answer is already known. + int errnum = 0; + PCRE2_SIZE erroffset = 0; + pcre2_code *code = pcre2_compile(reinterpret_cast(pattern), PCRE2_ZERO_TERMINATED, 0, &errnum, &erroffset, nullptr); + if (code == nullptr) { + return false; + } + pcre2_jit_compile(code, PCRE2_JIT_COMPLETE); + size_t jit_size = 0; + pcre2_pattern_info(code, PCRE2_INFO_JITSIZE, &jit_size); + pcre2_code_free(code); + if (jit_size == 0) { + return false; + } + + // There is machine code, so the match is bounded by the JIT stack and cannot run away. + // Now confirm this subject really does reach that bound on this release. Regex re; if (!re.compile(pattern)) { return false; @@ -447,7 +476,7 @@ TEST_CASE("Regex match", "[bench][regex]") BENCHMARK("exec with captures, miss") { RegexMatches matches; - return path.exec(SUBJECT_MISS, matches); + return path.exec(SUBJECT_PATH_MISS, matches); }; BENCHMARK("bool exec, extension pattern") From b3c273dde600fc87d8d7276c7ab51271344ef430 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 14:39:26 -0700 Subject: [PATCH 09/10] benchmark: keep the allocator interposer out of the timed binary The interposer was compiled into the same executable that runs the timed cases, so every allocation a timed operation made paid for a branch, a thread local read and an indirect call before reaching the real allocator. Measured by building the file twice, once with the wrappers and once without, medians of three rounds of 50 samples: compile path pattern 3026.32 ns 3058.20 ns -31.88 ns compile host pattern 2620.10 ns 2626.90 ns -6.80 ns compile extension pattern 3272.49 ns 3259.85 ns +12.64 ns copy a compiled pattern 8.87 ns 7.37 ns +1.49 ns The compiles are noise at about one percent of the operation. The copy is not: it makes a single allocation, one wrapper call costs about 1.5 ns, and the figure was running a fifth high. Matching is unaffected either way because it allocates nothing. One source now builds two targets. benchmark_Regex has no interposer compiled in, so a timed case measures the regex path and nothing else; nm confirms it exports none of the four allocator symbols. benchmark_Regex_alloc is built with BENCHMARK_REGEX_ALLOC, carries the interposer and runs only the counting cases, where the overhead does not matter because nothing is timed. --- tools/benchmark/CMakeLists.txt | 20 ++++++++++++++------ tools/benchmark/benchmark_Regex.cc | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/tools/benchmark/CMakeLists.txt b/tools/benchmark/CMakeLists.txt index 9d16e32639f..9e630731087 100644 --- a/tools/benchmark/CMakeLists.txt +++ b/tools/benchmark/CMakeLists.txt @@ -58,13 +58,21 @@ target_include_directories(benchmark_HuffmanDecode PRIVATE ${CMAKE_SOURCE_DIR}/l add_executable(benchmark_ascii_tolower benchmark_ascii_tolower.cc) target_link_libraries(benchmark_ascii_tolower PRIVATE Catch2::Catch2WithMain ts::tscore) +# Timing. No allocator interposition is compiled in, so a timed case measures the regex +# path alone; a wrapper call costs about 1.5 ns, which would be a fifth of an operation as +# short as copying a compiled pattern. add_executable(benchmark_Regex benchmark_Regex.cc) target_link_libraries(benchmark_Regex PRIVATE Catch2::Catch2WithMain ts::tsutil) + +# Allocation counting, from the same source. Nothing here is timed, so the interposer's +# own cost does not matter. +add_executable(benchmark_Regex_alloc benchmark_Regex.cc) +target_link_libraries(benchmark_Regex_alloc PRIVATE Catch2::Catch2WithMain ts::tsutil) +target_compile_definitions(benchmark_Regex_alloc PRIVATE BENCHMARK_REGEX_ALLOC) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - # The allocation counters interpose the system allocator, which needs the real - # symbols from the next object in the search order. RTLD_NEXT is a GNU extension; - # g++ and clang++ already define _GNU_SOURCE for C++ on Linux, but say so here - # rather than relying on it. - target_link_libraries(benchmark_Regex PRIVATE ${CMAKE_DL_LIBS}) - target_compile_definitions(benchmark_Regex PRIVATE _GNU_SOURCE) + # The counters interpose the system allocator, which needs the real symbols from the next + # object in the search order. RTLD_NEXT is a GNU extension; g++ and clang++ already define + # _GNU_SOURCE for C++ on Linux, but say so rather than relying on it. + target_link_libraries(benchmark_Regex_alloc PRIVATE ${CMAKE_DL_LIBS}) + target_compile_definitions(benchmark_Regex_alloc PRIVATE _GNU_SOURCE) endif() diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index ffbfd7724e7..6149819dc00 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -11,6 +11,13 @@ interpreter is the exception: it allocates a backtracking frames vector through the same allocator, so a match that runs interpreted does show up in the count. + This file builds two targets. benchmark_Regex times operations and contains no + interposer at all, so a timed case measures the regex path and nothing else; that + matters because a wrapper call costs about 1.5 ns, which is a fifth of an operation as + short as copying a compiled pattern. benchmark_Regex_alloc is built with + BENCHMARK_REGEX_ALLOC, carries the interposer, and runs only the counting cases, where + the overhead is irrelevant because nothing is being timed. + Interposing malloc is only wired up on Linux, where defining these symbols in the executable is enough. Elsewhere the counters stay at zero and the report says so, so a run on another platform still gives timings without quietly reporting zero allocations @@ -59,6 +66,7 @@ namespace { +#if defined(BENCHMARK_REGEX_ALLOC) struct AllocStats { unsigned long calls = 0; unsigned long bytes = 0; @@ -92,10 +100,11 @@ constexpr bool ALLOC_COUNTING_AVAILABLE = true; #else constexpr bool ALLOC_COUNTING_AVAILABLE = false; #endif +#endif // BENCHMARK_REGEX_ALLOC } // namespace -#if defined(__linux__) +#if defined(__linux__) && defined(BENCHMARK_REGEX_ALLOC) #include // Interpose the system allocator. Defining these in the executable takes precedence over @@ -306,7 +315,7 @@ realloc(void *p, size_t size) noexcept record(size); return real_realloc(p, size); } -#endif // __linux__ +#endif // __linux__ && BENCHMARK_REGEX_ALLOC // --------------------------------------------------------------------------- // Corpus @@ -390,6 +399,7 @@ jit_stack_subject() return subject; } +#if defined(BENCHMARK_REGEX_ALLOC) void report_allocations(char const *label, AllocStats const &stats, unsigned long operations) { @@ -401,6 +411,7 @@ report_allocations(char const *label, AllocStats const &stats, unsigned long ope static_cast(stats.calls) / static_cast(operations), static_cast(stats.bytes) / static_cast(operations), operations); } +#endif // BENCHMARK_REGEX_ALLOC } // namespace @@ -574,6 +585,7 @@ TEST_CASE("Regex match that exhausts the JIT stack", "[bench][regex]") // differ from the ones the inline buffer was sized against. // --------------------------------------------------------------------------- +#if defined(BENCHMARK_REGEX_ALLOC) TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") { constexpr unsigned long OPS = 10000; @@ -713,3 +725,4 @@ TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") printf("\n"); CHECK(true); } +#endif // BENCHMARK_REGEX_ALLOC From 2669b2b0a8dc9fb2d92722af7aa6b18e62ac42b2 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Sat, 12 Sep 2026 14:48:30 -0700 Subject: [PATCH 10/10] benchmark: build the timed cases only into the timing target Splitting the two targets left the timed cases compiled into both, so running benchmark_Regex_alloc without the [alloc] filter ran them under the interposer and reported timings from the binary built to count allocations. That is the thing the split was for. The timed section is now excluded when BENCHMARK_REGEX_ALLOC is defined: the timing target registers five cases and the allocation target registers one, whatever filter is given. Also publish the four resolved allocator pointers all together or not at all. Each wrapper does check its own pointer before using it, so a partial table was never dereferenced, but one invariant is easier to keep true than four, and a dlsym failure means the safe fallback paths are where every wrapper should stay. --- tools/benchmark/benchmark_Regex.cc | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tools/benchmark/benchmark_Regex.cc b/tools/benchmark/benchmark_Regex.cc index 6149819dc00..d4b3020c662 100644 --- a/tools/benchmark/benchmark_Regex.cc +++ b/tools/benchmark/benchmark_Regex.cc @@ -193,10 +193,16 @@ resolve_real_allocators() auto *c = reinterpret_cast(dlsym(RTLD_NEXT, "calloc")); auto *r = reinterpret_cast(dlsym(RTLD_NEXT, "realloc")); - real_free = f; - real_calloc = c; - real_realloc = r; - real_malloc = m; // published last: this is the pointer the early return above tests + // All four or none. Each wrapper does check its own pointer before using it, so a partial + // table would not be dereferenced, but an all-or-nothing rule is a much easier invariant + // to keep true than four separate ones, and a lookup failing at all means something is + // wrong enough that the safe paths are where every wrapper should stay. + if (m != nullptr && f != nullptr && c != nullptr && r != nullptr) { + real_free = f; + real_calloc = c; + real_realloc = r; + real_malloc = m; // published last: this is the pointer the early return above tests + } resolving = false; } @@ -419,6 +425,10 @@ report_allocations(char const *label, AllocStats const &stats, unsigned long ope // Timing // --------------------------------------------------------------------------- +// Timed cases. Compiled only into the timing target: the allocation target carries the +// interposer, and a timed case running under it would measure the wrapper as well. +#if !defined(BENCHMARK_REGEX_ALLOC) + TEST_CASE("Regex compile", "[bench][regex]") { BENCHMARK("compile path pattern") @@ -585,6 +595,8 @@ TEST_CASE("Regex match that exhausts the JIT stack", "[bench][regex]") // differ from the ones the inline buffer was sized against. // --------------------------------------------------------------------------- +#endif // !BENCHMARK_REGEX_ALLOC + #if defined(BENCHMARK_REGEX_ALLOC) TEST_CASE("Regex allocation counts", "[bench][regex][alloc]") {