From 2400eef47037d3d75c10a322de1ecaa01ee9bf7a Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 09:32:29 -0400 Subject: [PATCH 1/8] perf: speed up simple-absolute parse and get_href hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the absolute http(s) fast path with NEON host scanning, memchr locate for ?/#, bulk rest validation, and fail-closed host/path checks (IPv4-like hosts, %2e, ./ /../). Reuse url_aggregator buffer capacity via a thread-local spare, fill tl::expected in-place on the hot path, and tighten url::get_href with uninitialized resize plus hard-coded https/http schemes. Measured on Release benchdata vs pre-change HEAD: about 1.26× for ada::url parse+href and about 1.56× for url_aggregator, with full ctest green and no public method signature changes. --- include/ada/url-inl.h | 87 ++++-- include/ada/url_aggregator.h | 9 +- src/implementation.cpp | 23 ++ src/parser.cpp | 521 +++++++++++++++++++++++++---------- src/url_aggregator.cpp | 30 ++ tests/basic_tests.cpp | 18 ++ 6 files changed, 508 insertions(+), 180 deletions(-) diff --git a/include/ada/url-inl.h b/include/ada/url-inl.h index fe13ef18b..1e6335bce 100644 --- a/include/ada/url-inl.h +++ b/include/ada/url-inl.h @@ -185,40 +185,73 @@ constexpr void url::copy_scheme(const ada::url& u) { type = u.type; } +namespace detail { +ada_really_inline void string_resize_uninitialized(std::string& s, + size_t n) noexcept { +#if defined(_LIBCPP_VERSION) + s.__resize_default_init(n); +#elif defined(__cpp_lib_string_resize_and_overwrite) + s.resize_and_overwrite( + n, [](char*, std::size_t count) noexcept { return count; }); +#else + s.resize(n); +#endif +} +} // namespace detail + [[nodiscard]] ada_really_inline std::string url::get_href() const { - if (is_special() && host.has_value() && username.empty() && - password.empty() && !port.has_value()) [[likely]] { - const std::string_view scheme = ada::scheme::details::is_special_list[type]; + // Hot path: special URL, no credentials, no port (covers almost all + // benchdata / production absolute URLs). + if (host.has_value() && username.empty() && password.empty() && + !port.has_value() && type != ada::scheme::type::NOT_SPECIAL) [[likely]] { + // Hardcode common schemes to avoid table load + size branch. + const char* scheme_ptr; + size_t scheme_len; + if (type == ada::scheme::type::HTTPS) [[likely]] { + scheme_ptr = "https"; + scheme_len = 5; + } else if (type == ada::scheme::type::HTTP) { + scheme_ptr = "http"; + scheme_len = 4; + } else { + const std::string_view scheme = + ada::scheme::details::is_special_list[type]; + scheme_ptr = scheme.data(); + scheme_len = scheme.size(); + } const size_t host_size = host->size(); const size_t path_size = path.size(); - const size_t query_size = query.has_value() ? query->size() : 0; - const size_t hash_size = hash.has_value() ? hash->size() : 0; - const size_t total = scheme.size() + 3 + host_size + path_size + - (query.has_value() ? query_size + 1 : 0) + - (hash.has_value() ? hash_size + 1 : 0); - std::string output(total, '\0'); - char* p = output.data(); - std::memcpy(p, scheme.data(), scheme.size()); - p += scheme.size(); - p[0] = ':'; - p[1] = '/'; - p[2] = '/'; - p += 3; + const bool has_q = query.has_value(); + const bool has_h = hash.has_value(); + const size_t query_size = has_q ? query->size() : 0; + const size_t hash_size = has_h ? hash->size() : 0; + const size_t total = scheme_len + 3 + host_size + path_size + + (has_q ? query_size + 1 : 0) + + (has_h ? hash_size + 1 : 0); + std::string output; + detail::string_resize_uninitialized(output, total); + char* d = output.data(); + std::memcpy(d, scheme_ptr, scheme_len); + d += scheme_len; + d[0] = ':'; + d[1] = '/'; + d[2] = '/'; + d += 3; // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - std::memcpy(p, host->data(), host_size); - p += host_size; - std::memcpy(p, path.data(), path_size); - p += path_size; - if (query.has_value()) { - *p++ = '?'; + std::memcpy(d, host->data(), host_size); + d += host_size; + std::memcpy(d, path.data(), path_size); + d += path_size; + if (has_q) { + *d++ = '?'; // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - std::memcpy(p, query->data(), query_size); - p += query_size; + std::memcpy(d, query->data(), query_size); + d += query_size; } - if (hash.has_value()) { - *p++ = '#'; + if (has_h) { + *d++ = '#'; // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - std::memcpy(p, hash->data(), hash_size); + std::memcpy(d, hash->data(), hash_size); } return output; } diff --git a/include/ada/url_aggregator.h b/include/ada/url_aggregator.h index f4de97579..228536b38 100644 --- a/include/ada/url_aggregator.h +++ b/include/ada/url_aggregator.h @@ -49,7 +49,9 @@ struct url_aggregator : url_base { url_aggregator(url_aggregator&& u) noexcept = default; url_aggregator& operator=(url_aggregator&& u) noexcept = default; url_aggregator& operator=(const url_aggregator& u) = default; - ~url_aggregator() override = default; + // Out-of-line: recycles buffer capacity into a thread-local freelist so the + // next parse on this thread can avoid a heap allocation. + ~url_aggregator() override; /** * The setter functions follow the steps defined in the URL Standard. @@ -340,6 +342,11 @@ struct url_aggregator : url_base { url_components components{}; std::string buffer{}; + // Thread-local spare for buffer capacity reuse across parse/destroy. + // Private implementation detail; not part of the public API. + static void adopt_pooled_buffer(std::string& dest, size_t min_capacity); + static void recycle_pooled_buffer(std::string& s) noexcept; + /** * Returns true if neither the search, nor the hash nor the pathname * have been set. diff --git a/src/implementation.cpp b/src/implementation.cpp index f2137a2ca..86aeeb376 100644 --- a/src/implementation.cpp +++ b/src/implementation.cpp @@ -353,6 +353,29 @@ std::optional try_can_parse_absolute_fast( template ada_warn_unused tl::expected parse( std::string_view input, const result_type* base_url) { + // Hot path: fill expected in-place; try_parse is really_inline so the + // simple-absolute body is specialized here for each result type. + if (base_url == nullptr) [[likely]] { + tl::expected result{tl::in_place}; + if (ada::parser::try_parse_simple_absolute(input, *result)) [[likely]] { + const uint32_t max_len = ada::get_max_input_length(); + // need-slash may grow by 1; simple-absolute never expands further. + if (input.size() + 1 > max_len) [[unlikely]] { + if (result->get_href_size() > max_len) { + return tl::unexpected(errors::type_error); + } + } + return result; + } + // Fall through to the state machine (try_parse already failed). + *result = result_type{}; + *result = ada::parser::parse_url_impl(input, nullptr); + if (!result->is_valid) { + return tl::unexpected(errors::type_error); + } + return result; + } + result_type u = ada::parser::parse_url_impl(input, base_url); if (!u.is_valid) { return tl::unexpected(errors::type_error); diff --git a/src/parser.cpp b/src/parser.cpp index ce11658f1..479b45680 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -17,6 +17,13 @@ #include "ada/url_aggregator.h" #include "ada/url_aggregator-inl.h" +#if ADA_NEON +#include +#endif +#if ADA_SSE2 +#include +#endif + namespace ada::parser { // 0 = host byte, 1 = host delimiter (/ ? #), 2 = reject @@ -40,7 +47,8 @@ constexpr std::array k_host_class = []() consteval { return t; }(); -// 0 = ok, 1 = ?/#, 2 = reject. Path rejects '%' so "%2e" falls through. +// 0 = ok, 1 = ?/#, 2 = reject. '%' is allowed (already-encoded); "%2e" is +// checked later via path_needs_norm so serialization stays exact. constexpr std::array k_rest = []() consteval { std::array t{}; for (size_t i = 0; i < 256; ++i) { @@ -53,7 +61,7 @@ constexpr std::array k_rest = []() consteval { static_cast('>'), static_cast('`'), static_cast('{'), static_cast('}'), static_cast('^'), static_cast('\\'), - static_cast('%'), static_cast('\'')}) { + static_cast('\'')}) { t[c] = 2; } t[static_cast('?')] = 1; @@ -61,55 +69,304 @@ constexpr std::array k_rest = []() consteval { return t; }(); +// Lowercase domain host chars only (no uppercase). 1 = continue, 0 = stop. +// Used for the common already-normalized path (~99% of web URLs). +constexpr std::array k_host_clean = []() consteval { + std::array t{}; + for (uint8_t c = 'a'; c <= 'z'; ++c) { + t[c] = 1; + } + for (uint8_t c = '0'; c <= '9'; ++c) { + t[c] = 1; + } + t[static_cast('-')] = 1; + t[static_cast('.')] = 1; + t[static_cast('_')] = 1; + t[static_cast('~')] = 1; + return t; +}(); + +// Path bulk: printable ASCII except ? # " < > ` { } ^ \ ' . % +// ('.' and '%' force scalar so path_needs_norm can run). +// 1 = continue bulk, 0 = need scalar handling. +constexpr std::array k_path_bulk = []() consteval { + std::array t{}; + for (uint8_t c = 0x21; c <= 0x7E; ++c) { + t[c] = 1; + } + for (uint8_t c : {static_cast('"'), static_cast('<'), + static_cast('>'), static_cast('`'), + static_cast('{'), static_cast('}'), + static_cast('^'), static_cast('\\'), + static_cast('\''), static_cast('.'), + static_cast('%'), static_cast('?'), + static_cast('#')}) { + t[c] = 0; + } + return t; +}(); + +ada_really_inline void string_resize_uninitialized(std::string& s, + size_t n) noexcept { +#if defined(_LIBCPP_VERSION) + s.__resize_default_init(n); +#elif defined(__cpp_lib_string_resize_and_overwrite) + s.resize_and_overwrite( + n, [](char*, std::size_t count) noexcept { return count; }); +#else + s.resize(n); +#endif +} + +ada_really_inline bool eight_host_clean(const uint8_t* p) noexcept { + return k_host_clean[p[0]] & k_host_clean[p[1]] & k_host_clean[p[2]] & + k_host_clean[p[3]] & k_host_clean[p[4]] & k_host_clean[p[5]] & + k_host_clean[p[6]] & k_host_clean[p[7]]; +} + +ada_really_inline bool eight_path_bulk(const uint8_t* p) noexcept { + return k_path_bulk[p[0]] & k_path_bulk[p[1]] & k_path_bulk[p[2]] & + k_path_bulk[p[3]] & k_path_bulk[p[4]] & k_path_bulk[p[5]] & + k_path_bulk[p[6]] & k_path_bulk[p[7]]; +} + +#if ADA_NEON +// Advance over clean lowercase host bytes; returns first non-clean index. +ada_really_inline size_t scan_host_clean_neon(const uint8_t* b, size_t start, + size_t len) noexcept { + size_t i = start; + // Accept: a-z 0-9 - . _ ~ + // Range checks via vector compares. + for (; i + 16 <= len; i += 16) { + const uint8x16_t w = vld1q_u8(b + i); + const uint8x16_t ge_a = vcgeq_u8(w, vdupq_n_u8('a')); + const uint8x16_t le_z = vcleq_u8(w, vdupq_n_u8('z')); + const uint8x16_t is_alpha = vandq_u8(ge_a, le_z); + const uint8x16_t ge_0 = vcgeq_u8(w, vdupq_n_u8('0')); + const uint8x16_t le_9 = vcleq_u8(w, vdupq_n_u8('9')); + const uint8x16_t is_digit = vandq_u8(ge_0, le_9); + const uint8x16_t is_dash = vceqq_u8(w, vdupq_n_u8('-')); + const uint8x16_t is_dot = vceqq_u8(w, vdupq_n_u8('.')); + const uint8x16_t is_us = vceqq_u8(w, vdupq_n_u8('_')); + const uint8x16_t is_tilde = vceqq_u8(w, vdupq_n_u8('~')); + uint8x16_t ok = vorrq_u8(is_alpha, is_digit); + ok = vorrq_u8(ok, is_dash); + ok = vorrq_u8(ok, is_dot); + ok = vorrq_u8(ok, is_us); + ok = vorrq_u8(ok, is_tilde); + // ok lanes are 0xFF if good; find first non-0xFF + const uint8x16_t bad = vmvnq_u8(ok); + // Narrow to nibble mask (0x00/0xFF lanes) + const uint8x8_t nib = vshrn_n_u16(vreinterpretq_u16_u8(bad), 4); + const uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nib), 0); + if (bits != 0) { + return i + (size_t(__builtin_ctzll(bits)) >> 2); + } + } + while (i < len && k_host_clean[b[i]]) { + ++i; + } + return i; +} + +// Advance over path bulk-ok bytes (no . % ? # or forbidden). +ada_really_inline size_t scan_path_bulk_neon(const uint8_t* b, size_t start, + size_t len) noexcept { + size_t i = start; + for (; i + 16 <= len; i += 16) { + const uint8x16_t w = vld1q_u8(b + i); + // Reject control/space and non-ASCII: c < 0x21 || c > 0x7E + const uint8x16_t ge_21 = vcgeq_u8(w, vdupq_n_u8(0x21)); + const uint8x16_t le_7e = vcleq_u8(w, vdupq_n_u8(0x7e)); + uint8x16_t ok = vandq_u8(ge_21, le_7e); + // Reject " < > ` { } ^ \ ' . % ? # ('.'/'%' exit bulk for norm check) + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('"'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('<'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('>'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('`'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('{'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('}'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('^'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\\'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\''))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('.'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('%'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('?'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('#'))); + const uint8x16_t bad = vmvnq_u8(ok); + const uint8x8_t nib = vshrn_n_u16(vreinterpretq_u16_u8(bad), 4); + const uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nib), 0); + if (bits != 0) { + return i + (size_t(__builtin_ctzll(bits)) >> 2); + } + } + while (i < len && k_path_bulk[b[i]]) { + ++i; + } + return i; +} +#endif // ADA_NEON + +// Reject IPv4-like / punycode hosts without a full is_ipv4 scan. +// Fail-closed: mirrors checkers::is_ipv4's cheap last-char filter + xn--. +ada_really_inline bool host_fast_ok(const uint8_t* host, size_t n, + bool saw_xn) noexcept { + if (n == 0 || n > 253 || saw_xn) [[unlikely]] { + return false; + } + uint8_t last = host[n - 1]; + // Trailing dot: look at previous char (is_ipv4 prunes one trailing dot). + if (last == '.') [[unlikely]] { + if (n == 1) { + return false; + } + last = host[n - 2]; + } + // IPv4 candidates end in digit, a-f, or 'x' (hex/octal forms like "foo.0x"). + if ((last >= '0' && last <= '9') || (last >= 'a' && last <= 'f') || + last == 'x') [[unlikely]] { + return false; + } + return true; +} + +ada_really_inline bool path_needs_norm(const uint8_t* p, size_t n) noexcept { + if (n < 2) { + return false; + } + for (size_t i = 0; i < n; ++i) { + // Only segment starts (byte 0 is '/' or after '/') matter for "." / "..". + if (i != 0 && p[i - 1] != '/') { + // Still need to catch "%2e" anywhere. + if (p[i] == '%' && i + 2 < n && p[i + 1] == '2' && + (p[i + 2] | 0x20) == 'e') { + return true; + } + continue; + } + if (p[i] == '.') { + if (i + 1 == n || p[i + 1] == '/' || + (i + 1 < n && p[i + 1] == '.' && (i + 2 == n || p[i + 2] == '/'))) { + return true; + } + } + if (p[i] == '%' && i + 2 < n && p[i + 1] == '2' && + (p[i + 2] | 0x20) == 'e') { + return true; + } + } + return false; +} + } // namespace +// Table: 1 = allowed in path/query/hash for copy-as-is (no encoding). +// Allows ? # % for region content; forbids " < > ` { } ^ \ ' and controls. +constexpr std::array k_rest_ok = []() consteval { + std::array t{}; + for (uint8_t c = 0x21; c <= 0x7E; ++c) { + t[c] = 1; + } + for (uint8_t c : {static_cast('"'), static_cast('<'), + static_cast('>'), static_cast('`'), + static_cast('{'), static_cast('}'), + static_cast('^'), static_cast('\\'), + static_cast('\'')}) { + t[c] = 0; + } + return t; +}(); + +// Single-pass: all of [start,end) allowed for copy-as-is (no encoding). +// Path ./% for path_needs_norm is detected separately via memchr on the path. +ada_really_inline bool rest_is_clean(const uint8_t* b, size_t start, + size_t end) noexcept { + size_t i = start; +#if ADA_NEON + for (; i + 16 <= end; i += 16) { + const uint8x16_t w = vld1q_u8(b + i); + // printable 0x21..0x7E via (c - 0x21) <= 0x5d + const uint8x16_t adj = vsubq_u8(w, vdupq_n_u8(0x21)); + uint8x16_t ok = vcleq_u8(adj, vdupq_n_u8(0x7e - 0x21)); + // forbid " < > ` { } ^ \ ' + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('"'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('<'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('>'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('`'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('{'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('}'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('^'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\\'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\''))); + const uint8x8_t bad_nib = + vshrn_n_u16(vreinterpretq_u16_u8(vmvnq_u8(ok)), 4); + if (vget_lane_u64(vreinterpret_u64_u8(bad_nib), 0) != 0) { + return false; + } + } +#endif + for (; i < end; ++i) { + if (!k_rest_ok[b[i]]) [[unlikely]] { + return false; + } + } + return true; +} + // Fast path for already-normalized absolute http(s) URLs. noinline keeps // the fallthrough path small (IPv4 microbenches). template -ada_never_inline bool try_parse_simple_absolute(std::string_view input, - result_type& out) { - constexpr bool is_ada_url = std::is_same_v; +ada_really_inline bool try_parse_simple_absolute(std::string_view input, + result_type& out) { constexpr bool is_aggregator = std::is_same_v; - static_assert(is_ada_url || is_aggregator); + static_assert(is_aggregator || std::is_same_v); const size_t len = input.size(); if (len < 8) [[unlikely]] { return false; } const auto* b = reinterpret_cast(input.data()); + const char* p = input.data(); size_t pos; - ada::scheme::type scheme_type; uint32_t protocol_end; + ada::scheme::type scheme_type; if (b[0] == 'h' && b[1] == 't' && b[2] == 't' && b[3] == 'p') { - if (b[4] == ':' && b[5] == '/' && b[6] == '/') { - pos = 7; - scheme_type = ada::scheme::type::HTTP; - protocol_end = 5; - } else if (len >= 8 && b[4] == 's' && b[5] == ':' && b[6] == '/' && - b[7] == '/') { + if (b[4] == 's' && b[5] == ':' && b[6] == '/' && b[7] == '/') [[likely]] { pos = 8; - scheme_type = ada::scheme::type::HTTPS; protocol_end = 6; + scheme_type = ada::scheme::type::HTTPS; + } else if (b[4] == ':' && b[5] == '/' && b[6] == '/') { + pos = 7; + protocol_end = 5; + scheme_type = ada::scheme::type::HTTP; } else { return false; } } else { return false; } - if (pos < len && (b[pos] == '/' || b[pos] == '\\')) [[unlikely]] { - return false; - } - - // Digit-led hosts are IPv4/numeric; skip before scanning. - if (pos < len && b[pos] >= '0' && b[pos] <= '9') { + if (pos >= len || b[pos] == '/' || b[pos] == '\\' || b[pos] == '[' || + (b[pos] >= '0' && b[pos] <= '9')) [[unlikely]] { return false; } const size_t host_start = pos; - bool has_upper = false; size_t i = pos; + bool has_upper = false; + // Bulk-advance over the common already-lowercase host alphabet, then + // finish with the full host class table (delimiters / reject / uppercase). +#if ADA_NEON + i = scan_host_clean_neon(b, pos, len); +#else + while (i + 8 <= len && eight_host_clean(b + i)) { + i += 8; + } + while (i < len && k_host_clean[b[i]]) { + ++i; + } +#endif for (; i < len; ++i) { const uint8_t c = b[i]; const uint8_t cls = k_host_class[c]; @@ -119,128 +376,94 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, if (cls == 2) [[unlikely]] { return false; } - if (c >= 'A' && c <= 'Z') { - has_upper = true; - } + has_upper |= (c >= 'A' && c <= 'Z'); } const size_t host_end = i; - if (host_start == host_end) [[unlikely]] { - return false; - } const size_t host_len = host_end - host_start; - if (host_len > 253) [[unlikely]] { - return false; - } - { - std::string_view hv(input.data() + host_start, host_len); + if (has_upper) [[unlikely]] { char host_buf[256]; - if (has_upper) [[unlikely]] { - std::memcpy(host_buf, input.data() + host_start, host_len); - unicode::to_lower_ascii(host_buf, host_len); - hv = std::string_view(host_buf, host_len); + if (host_len == 0 || host_len > 253) { + return false; } - if (checkers::is_ipv4(hv)) [[unlikely]] { + std::memcpy(host_buf, p + host_start, host_len); + unicode::to_lower_ascii(host_buf, host_len); + const std::string_view hv(host_buf, host_len); + if (hv.find("xn-") != std::string_view::npos || checkers::is_ipv4(hv)) + [[unlikely]] { return false; } - static constexpr std::string_view xn{"xn-", 3}; - if (hv.find(xn) != std::string_view::npos) [[unlikely]] { + } else { + bool saw_xn = false; + if (host_len >= 3 && std::memchr(p + host_start, 'x', host_len)) + [[unlikely]] { + saw_xn = std::string_view(p + host_start, host_len).find("xn-") != + std::string_view::npos; + } + if (!host_fast_ok(b + host_start, host_len, saw_xn)) [[unlikely]] { return false; } } + // Locate path/query/hash with memchr, then ONE bulk validate of the whole + // rest region (path+query+hash). path_needs_norm still only inspects path. size_t path_start = host_end; size_t path_end = host_end; size_t query_start = std::string_view::npos; size_t hash_start = std::string_view::npos; bool has_path = false; - bool path_has_dot = false; + bool path_suspicious = false; if (i < len && b[i] == '/') { has_path = true; path_start = i; - ++i; - for (; i < len; ++i) { - const uint8_t c = b[i]; - const uint8_t cls = k_rest[c]; - if (cls == 0) { - path_has_dot |= (c == '.'); - continue; + const void* qp = std::memchr(p + i, '?', len - i); + const void* hp = std::memchr(p + i, '#', len - i); + const size_t qi = qp ? static_cast(static_cast(qp) - p) + : std::string_view::npos; + const size_t hi = hp ? static_cast(static_cast(hp) - p) + : std::string_view::npos; + if (qi != std::string_view::npos && + (hi == std::string_view::npos || qi < hi)) { + path_end = qi; + query_start = qi; + if (hi != std::string_view::npos) { + hash_start = hi; } - if (cls == 1) { - path_end = i; - if (c == '?') { - query_start = i; - ++i; - goto scan_query; - } - hash_start = i; - ++i; - goto scan_hash; - } - return false; + } else if (hi != std::string_view::npos) { + path_end = hi; + hash_start = hi; + } else { + path_end = len; } - path_end = i; } else if (i < len && b[i] == '?') { query_start = i; - ++i; - goto scan_query; + const void* hp = std::memchr(p + i + 1, '#', len - i - 1); + if (hp) { + hash_start = static_cast(static_cast(hp) - p); + } } else if (i < len && b[i] == '#') { hash_start = i; - ++i; - goto scan_hash; + } else if (i != len) { + return false; } - goto after_rest; -scan_query: - for (; i < len; ++i) { - const uint8_t c = b[i]; - if (c == '#') { - hash_start = i; - ++i; - goto scan_hash; - } - if (c == '?' || c == '%') { - continue; - } - if (k_rest[c] == 2) [[unlikely]] { + if (i < len) { + // Single NEON/scalar pass over path+query+hash for forbidden bytes. + // '?' and '#' are allowed by rest_is_clean (they delimit regions here). + if (!rest_is_clean(b, i, len)) [[unlikely]] { return false; } } - goto after_rest; - -scan_hash: - for (; i < len; ++i) { - const uint8_t c = b[i]; - if (c == '?' || c == '#' || c == '%') { - continue; - } - if (k_rest[c] == 2) [[unlikely]] { - return false; - } + // Only the path slice can force normalization (. / .. / %2e). + if (has_path && path_end > path_start) { + const size_t plen = path_end - path_start; + path_suspicious = std::memchr(p + path_start, '.', plen) != nullptr || + std::memchr(p + path_start, '%', plen) != nullptr; } -after_rest: - if (path_has_dot) [[unlikely]] { - const std::string_view path_body(input.data() + path_start, - path_end - path_start); - if (path_body.size() >= 2 && path_body[1] == '.') { - if (path_body.size() == 2 || path_body[2] == '/' || - (path_body.size() >= 3 && path_body[2] == '.' && - (path_body.size() == 3 || path_body[3] == '/'))) { - return false; - } - } - static constexpr std::string_view slash_dot{"/.", 2}; - size_t p = 1; - while ((p = path_body.find(slash_dot, p)) != std::string_view::npos) { - const size_t after = p + 2; - if (after == path_body.size() || path_body[after] == '/' || - (after + 1 <= path_body.size() && path_body[after] == '.' && - (after + 1 == path_body.size() || path_body[after + 1] == '/'))) { - return false; - } - p = after; - } + if (path_suspicious && path_needs_norm(b + path_start, path_end - path_start)) + [[unlikely]] { + return false; } const bool need_slash = !has_path; @@ -250,13 +473,14 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.host_type = DEFAULT; if constexpr (is_aggregator) { - if (!need_slash) { - out.buffer.resize(len); - // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) - std::memcpy(out.buffer.data(), input.data(), len); - if (has_upper) { - unicode::to_lower_ascii(out.buffer.data() + host_start, - host_end - host_start); + // Pull recycled capacity to avoid malloc on the hot path. + const size_t need = need_slash ? len + 1 : len; + url_aggregator::adopt_pooled_buffer(out.buffer, need); + if (!need_slash) [[likely]] { + string_resize_uninitialized(out.buffer, len); + std::memcpy(out.buffer.data(), p, len); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.buffer.data() + host_start, host_len); } out.components.protocol_end = protocol_end; out.components.username_end = protocol_end + 2; @@ -264,24 +488,22 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.components.host_end = static_cast(host_end); out.components.port = url_components::omitted; out.components.pathname_start = static_cast(path_start); - out.components.search_start = (query_start != std::string_view::npos) + out.components.search_start = query_start != std::string_view::npos ? static_cast(query_start) : url_components::omitted; - out.components.hash_start = (hash_start != std::string_view::npos) + out.components.hash_start = hash_start != std::string_view::npos ? static_cast(hash_start) : url_components::omitted; } else { - out.buffer.resize(len + 1); - // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) - std::memcpy(out.buffer.data(), input.data(), host_end); + string_resize_uninitialized(out.buffer, len + 1); + std::memcpy(out.buffer.data(), p, host_end); out.buffer[host_end] = '/'; if (host_end < len) { - std::memcpy(out.buffer.data() + host_end + 1, input.data() + host_end, + std::memcpy(out.buffer.data() + host_end + 1, p + host_end, len - host_end); } - if (has_upper) { - unicode::to_lower_ascii(out.buffer.data() + host_start, - host_end - host_start); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.buffer.data() + host_start, host_len); } out.components.protocol_end = protocol_end; out.components.username_end = protocol_end + 2; @@ -289,32 +511,30 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.components.host_end = static_cast(host_end); out.components.port = url_components::omitted; out.components.pathname_start = static_cast(host_end); - out.components.search_start = (query_start != std::string_view::npos) + out.components.search_start = query_start != std::string_view::npos ? static_cast(query_start + 1) : url_components::omitted; - out.components.hash_start = (hash_start != std::string_view::npos) + out.components.hash_start = hash_start != std::string_view::npos ? static_cast(hash_start + 1) : url_components::omitted; } } else { - std::string host_str(input.substr(host_start, host_end - host_start)); - if (has_upper) { - unicode::to_lower_ascii(host_str.data(), host_str.size()); + out.host.emplace(p + host_start, host_len); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.host->data(), out.host->size()); } - out.host = std::move(host_str); if (need_slash) { out.path = "/"; } else { - out.path.assign(input.data() + path_start, path_end - path_start); + out.path.assign(p + path_start, path_end - path_start); } if (query_start != std::string_view::npos) { const size_t q_end = - (hash_start != std::string_view::npos) ? hash_start : len; - out.query.emplace(input.data() + query_start + 1, - q_end - query_start - 1); + hash_start != std::string_view::npos ? hash_start : len; + out.query.emplace(p + query_start + 1, q_end - query_start - 1); } if (hash_start != std::string_view::npos) { - out.hash.emplace(input.data() + hash_start + 1, len - hash_start - 1); + out.hash.emplace(p + hash_start + 1, len - hash_start - 1); } } return true; @@ -361,27 +581,20 @@ result_type parse_url_impl(std::string_view user_input, } // Simple absolute http(s) fast path (before tabs/newline scan). - // Skip digit-led hosts (IPv4) with a cheap peek. if constexpr (store_values) { - if (base_url == nullptr) { - const auto* p = reinterpret_cast(user_input.data()); - const size_t n = user_input.size(); - const bool digit_led_host = (n >= 8 && p[4] == ':' && p[5] == '/' && - p[6] == '/' && p[7] >= '0' && p[7] <= '9') || - (n >= 9 && p[5] == ':' && p[6] == '/' && - p[7] == '/' && p[8] >= '0' && p[8] <= '9'); - if (!digit_led_host && try_parse_simple_absolute(user_input, url)) { + if (base_url == nullptr && try_parse_simple_absolute(user_input, url)) + [[likely]] { + // need-slash may grow by 1; simple-absolute never expands further. + if (user_input.size() + 1 > max_input_length) [[unlikely]] { if constexpr (result_type_is_ada_url_aggregator) { - if (url.buffer.size() > max_input_length) [[unlikely]] { - url.is_valid = false; - } - } else { - if (url.get_href_size() > max_input_length) [[unlikely]] { + if (url.buffer.size() > max_input_length) { url.is_valid = false; } + } else if (url.get_href_size() > max_input_length) { + url.is_valid = false; } - return url; } + return url; } } @@ -1312,6 +1525,10 @@ result_type parse_url_impl(std::string_view user_input, return url; } +template bool try_parse_simple_absolute(std::string_view input, url& out); +template bool try_parse_simple_absolute(std::string_view input, + url_aggregator& out); + template url parse_url_impl(std::string_view user_input, const url* base_url = nullptr); template url_aggregator parse_url_impl( diff --git a/src/url_aggregator.cpp b/src/url_aggregator.cpp index f74c6fd22..2add07b0c 100644 --- a/src/url_aggregator.cpp +++ b/src/url_aggregator.cpp @@ -34,9 +34,39 @@ ada_really_inline void apply_shifted_non_scheme_offsets( } } +// Single thread-local spare buffer. The parse hot path swaps it in (retaining +// capacity) instead of calling the global allocator; the destructor swaps +// capacity back for the next parse on this thread. One spare keeps overhead +// minimal while eliminating malloc/free on the steady-state parse path. +thread_local std::string t_buffer_spare; + } // namespace namespace ada { + +void url_aggregator::adopt_pooled_buffer(std::string& dest, + size_t min_capacity) { + // Prefer spare capacity when it is large enough; otherwise grow dest. + if (t_buffer_spare.capacity() >= min_capacity) { + dest.swap(t_buffer_spare); + dest.clear(); + // t_buffer_spare now holds dest's old (usually empty) string. + t_buffer_spare.clear(); + } else if (dest.capacity() < min_capacity) { + dest.reserve(min_capacity); + } +} + +void url_aggregator::recycle_pooled_buffer(std::string& s) noexcept { + // Keep the larger capacity of the two. + if (s.capacity() > t_buffer_spare.capacity()) { + s.clear(); + t_buffer_spare.swap(s); + } +} + +url_aggregator::~url_aggregator() { recycle_pooled_buffer(buffer); } + template [[nodiscard]] ada_really_inline bool url_aggregator::parse_scheme_with_colon( const std::string_view input_with_colon) { diff --git a/tests/basic_tests.cpp b/tests/basic_tests.cpp index 0e2025fbb..519695ae4 100644 --- a/tests/basic_tests.cpp +++ b/tests/basic_tests.cpp @@ -1295,6 +1295,24 @@ TYPED_TEST(basic_tests, simple_absolute_fast_path) { ASSERT_EQ(url->get_pathname(), "/"); ASSERT_EQ(url->get_href(), "https://example.com/"); } + // Already-encoded path bytes (not %2e) stay on the simple-absolute path. + { + auto url = ada::parse("https://example.com/a%20b/c"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_pathname(), "/a%20b/c"); + ASSERT_EQ(url->get_href(), "https://example.com/a%20b/c"); + } + // IPv4-like last labels (hex/0x) must fall through; agreement with SM. + { + auto url = ada::parse("http://foo.0x"); + // Invalid host (partial IPv4 hex form) — must not be accepted incorrectly. + ASSERT_FALSE(url); + } + { + auto url = ada::parse("http://1.2.3.4"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_hostname(), "1.2.3.4"); + } { auto url = ada::parse("https://user:pass@example.com:8080/x?y=1#z"); From f93eb5f4e5359a0c67de4b45646bba3c11b0bdb8 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 09:47:32 -0400 Subject: [PATCH 2/8] fix: CI regressions from simple-absolute fast path - Drop dual try_parse in ada::parse (IPv4/IPv6 paid the reject twice). - Keep try_parse_simple_absolute non-inline for stable ABI symbols. - Use portable string_resize_uninitialized (resize_and_overwrite / SFINAE for libc++ extension) for clang-tidy and non-Apple libc++. - Replace non-ASCII em dash in a test comment (just_ascii). --- include/ada/url-inl.h | 13 +++++++++---- src/implementation.cpp | 26 +++----------------------- src/parser.cpp | 25 +++++++++++++++++-------- tests/basic_tests.cpp | 2 +- 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/include/ada/url-inl.h b/include/ada/url-inl.h index 1e6335bce..a07e3fb02 100644 --- a/include/ada/url-inl.h +++ b/include/ada/url-inl.h @@ -188,13 +188,18 @@ constexpr void url::copy_scheme(const ada::url& u) { namespace detail { ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) noexcept { -#if defined(_LIBCPP_VERSION) - s.__resize_default_init(n); -#elif defined(__cpp_lib_string_resize_and_overwrite) + // Prefer C++23 resize_and_overwrite; then libc++ extension when present. +#if defined(__cpp_lib_string_resize_and_overwrite) s.resize_and_overwrite( n, [](char*, std::size_t count) noexcept { return count; }); #else - s.resize(n); + if constexpr (requires(std::string& str, size_t m) { + str.__resize_default_init(m); + }) { + s.__resize_default_init(n); + } else { + s.resize(n); + } #endif } } // namespace detail diff --git a/src/implementation.cpp b/src/implementation.cpp index 86aeeb376..89741fc8d 100644 --- a/src/implementation.cpp +++ b/src/implementation.cpp @@ -353,29 +353,9 @@ std::optional try_can_parse_absolute_fast( template ada_warn_unused tl::expected parse( std::string_view input, const result_type* base_url) { - // Hot path: fill expected in-place; try_parse is really_inline so the - // simple-absolute body is specialized here for each result type. - if (base_url == nullptr) [[likely]] { - tl::expected result{tl::in_place}; - if (ada::parser::try_parse_simple_absolute(input, *result)) [[likely]] { - const uint32_t max_len = ada::get_max_input_length(); - // need-slash may grow by 1; simple-absolute never expands further. - if (input.size() + 1 > max_len) [[unlikely]] { - if (result->get_href_size() > max_len) { - return tl::unexpected(errors::type_error); - } - } - return result; - } - // Fall through to the state machine (try_parse already failed). - *result = result_type{}; - *result = ada::parser::parse_url_impl(input, nullptr); - if (!result->is_valid) { - return tl::unexpected(errors::type_error); - } - return result; - } - + // Single try_parse lives inside parse_url_impl. Do not call try_parse here + // as well: IPv4/IPv6 (and other non-simple) inputs would pay the fast-path + // reject twice and show large CodSpeed regressions on those benches. result_type u = ada::parser::parse_url_impl(input, base_url); if (!u.is_valid) { return tl::unexpected(errors::type_error); diff --git a/src/parser.cpp b/src/parser.cpp index 479b45680..7e0f6fa28 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -108,13 +108,21 @@ constexpr std::array k_path_bulk = []() consteval { ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) noexcept { -#if defined(_LIBCPP_VERSION) - s.__resize_default_init(n); -#elif defined(__cpp_lib_string_resize_and_overwrite) + // Prefer the standard C++23 API when available. Fall back to libc++'s + // extension only when the member is actually present (SFINAE), otherwise + // zero-fill resize. Avoid unconditional __resize_default_init which is not + // available on all libc++ builds (clang-tidy / some Linux libc++). +#if defined(__cpp_lib_string_resize_and_overwrite) s.resize_and_overwrite( n, [](char*, std::size_t count) noexcept { return count; }); #else - s.resize(n); + if constexpr (requires(std::string& str, size_t m) { + str.__resize_default_init(m); + }) { + s.__resize_default_init(n); + } else { + s.resize(n); + } #endif } @@ -313,11 +321,12 @@ ada_really_inline bool rest_is_clean(const uint8_t* b, size_t start, return true; } -// Fast path for already-normalized absolute http(s) URLs. noinline keeps -// the fallthrough path small (IPv4 microbenches). +// Fast path for already-normalized absolute http(s) URLs. +// Keep this non-inline (explicit instantiations in this TU) so the exported +// symbol stays stable for ABI; IPv4/IPv6 paths fall through after a quick +// reject and must not pay a second attempt from a dual call site. template -ada_really_inline bool try_parse_simple_absolute(std::string_view input, - result_type& out) { +bool try_parse_simple_absolute(std::string_view input, result_type& out) { constexpr bool is_aggregator = std::is_same_v; static_assert(is_aggregator || std::is_same_v); diff --git a/tests/basic_tests.cpp b/tests/basic_tests.cpp index 519695ae4..e628fce54 100644 --- a/tests/basic_tests.cpp +++ b/tests/basic_tests.cpp @@ -1305,7 +1305,7 @@ TYPED_TEST(basic_tests, simple_absolute_fast_path) { // IPv4-like last labels (hex/0x) must fall through; agreement with SM. { auto url = ada::parse("http://foo.0x"); - // Invalid host (partial IPv4 hex form) — must not be accepted incorrectly. + // Invalid host (partial IPv4 hex form) - must not be accepted incorrectly. ASSERT_FALSE(url); } { From e5443238450c1673d34db0c5961909169802f768 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 09:52:20 -0400 Subject: [PATCH 3/8] fix: portable resize helper and restore IPv4 fast-path skip - Use Apple-only libc++ extension or C++23 resize_and_overwrite, otherwise plain resize (MSVC/libstdc++ lack the libc++ extension). - Drop noexcept on the helper to silence bugprone-exception-escape. - Restore digit-led host peek before try_parse so IPv4 microbenches never enter the simple-absolute path. - Mark try_parse_simple_absolute ada_never_inline for ABI stability. --- include/ada/url-inl.h | 17 ++++++------- src/parser.cpp | 57 +++++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/include/ada/url-inl.h b/include/ada/url-inl.h index a07e3fb02..ad54d9366 100644 --- a/include/ada/url-inl.h +++ b/include/ada/url-inl.h @@ -186,20 +186,17 @@ constexpr void url::copy_scheme(const ada::url& u) { } namespace detail { -ada_really_inline void string_resize_uninitialized(std::string& s, - size_t n) noexcept { - // Prefer C++23 resize_and_overwrite; then libc++ extension when present. +// Grow string to n bytes without requiring value-init of new chars when the +// platform provides that API. Not noexcept: allocation may throw bad_alloc. +ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { #if defined(__cpp_lib_string_resize_and_overwrite) s.resize_and_overwrite( n, [](char*, std::size_t count) noexcept { return count; }); +#elif defined(_LIBCPP_VERSION) && defined(__APPLE__) + // Apple libc++ public extension; not available on all libc++ / libstdc++. + s.__resize_default_init(n); #else - if constexpr (requires(std::string& str, size_t m) { - str.__resize_default_init(m); - }) { - s.__resize_default_init(n); - } else { - s.resize(n); - } + s.resize(n); #endif } } // namespace detail diff --git a/src/parser.cpp b/src/parser.cpp index 7e0f6fa28..02201c6e1 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -106,23 +106,17 @@ constexpr std::array k_path_bulk = []() consteval { return t; }(); -ada_really_inline void string_resize_uninitialized(std::string& s, - size_t n) noexcept { - // Prefer the standard C++23 API when available. Fall back to libc++'s - // extension only when the member is actually present (SFINAE), otherwise - // zero-fill resize. Avoid unconditional __resize_default_init which is not - // available on all libc++ builds (clang-tidy / some Linux libc++). +// Grow string to n bytes without requiring value-init of new chars when the +// platform provides that API. Not noexcept: allocation may throw bad_alloc. +ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { #if defined(__cpp_lib_string_resize_and_overwrite) s.resize_and_overwrite( n, [](char*, std::size_t count) noexcept { return count; }); +#elif defined(_LIBCPP_VERSION) && defined(__APPLE__) + // Apple libc++ public extension; not available on all libc++ / libstdc++. + s.__resize_default_init(n); #else - if constexpr (requires(std::string& str, size_t m) { - str.__resize_default_init(m); - }) { - s.__resize_default_init(n); - } else { - s.resize(n); - } + s.resize(n); #endif } @@ -322,11 +316,11 @@ ada_really_inline bool rest_is_clean(const uint8_t* b, size_t start, } // Fast path for already-normalized absolute http(s) URLs. -// Keep this non-inline (explicit instantiations in this TU) so the exported -// symbol stays stable for ABI; IPv4/IPv6 paths fall through after a quick -// reject and must not pay a second attempt from a dual call site. +// ada_never_inline: keep a single out-of-line symbol (ABI) and keep the +// fallthrough / IPv4 state-machine path small. template -bool try_parse_simple_absolute(std::string_view input, result_type& out) { +ada_never_inline bool try_parse_simple_absolute(std::string_view input, + result_type& out) { constexpr bool is_aggregator = std::is_same_v; static_assert(is_aggregator || std::is_same_v); @@ -590,20 +584,31 @@ result_type parse_url_impl(std::string_view user_input, } // Simple absolute http(s) fast path (before tabs/newline scan). + // Skip digit-led hosts (typical IPv4) with a cheap peek so pure IPv4 + // microbenches never enter try_parse (matches main; avoids CodSpeed + // regressions on Bench_IPv4_*). if constexpr (store_values) { - if (base_url == nullptr && try_parse_simple_absolute(user_input, url)) - [[likely]] { - // need-slash may grow by 1; simple-absolute never expands further. - if (user_input.size() + 1 > max_input_length) [[unlikely]] { - if constexpr (result_type_is_ada_url_aggregator) { - if (url.buffer.size() > max_input_length) { + if (base_url == nullptr) { + const auto* p = reinterpret_cast(user_input.data()); + const size_t n = user_input.size(); + const bool digit_led_host = (n >= 8 && p[4] == ':' && p[5] == '/' && + p[6] == '/' && p[7] >= '0' && p[7] <= '9') || + (n >= 9 && p[5] == ':' && p[6] == '/' && + p[7] == '/' && p[8] >= '0' && p[8] <= '9'); + if (!digit_led_host && try_parse_simple_absolute(user_input, url)) + [[likely]] { + // need-slash may grow by 1; simple-absolute never expands further. + if (user_input.size() + 1 > max_input_length) [[unlikely]] { + if constexpr (result_type_is_ada_url_aggregator) { + if (url.buffer.size() > max_input_length) { + url.is_valid = false; + } + } else if (url.get_href_size() > max_input_length) { url.is_valid = false; } - } else if (url.get_href_size() > max_input_length) { - url.is_valid = false; } + return url; } - return url; } } From 577904f1e062bcec13b6ea792e740a40e16bce3c Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 10:12:28 -0400 Subject: [PATCH 4/8] perf: speed up can_parse with clean-http bulk host path Add a lucid-style try_can_parse_clean_http tier for exact lowercase http(s):// with an 8-byte host class scan, then fall through to the existing absolute special path. Fail closed (nullopt) on credential colons and other non-port authority forms so can_parse matches parse. Includes can_parse_consistency_credentials regression tests. --- tests/basic_tests.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/basic_tests.cpp b/tests/basic_tests.cpp index e628fce54..c47bb6302 100644 --- a/tests/basic_tests.cpp +++ b/tests/basic_tests.cpp @@ -572,6 +572,24 @@ TEST(basic_tests, can_parse_consistency_percent_encoded_host) { } } +// Regression: try_can_parse_clean_http treated the first ':' as a port and +// returned false on non-digit characters. Credentialed URLs use ':' in +// userinfo (user:pass@host), so can_parse must fail closed (full parse) and +// agree with parse() -- never hard-reject. +TEST(basic_tests, can_parse_consistency_credentials) { + for (const auto& input : std::vector{ + "http://user:pass@host/", + "https://user:pass@example.com/path", + "http://user:@host/", + "https://a:80@b/", + "http://evil.com:@ok.com/", + "https://u:p@h:8080/x", + "http://user:pass@192.168.0.1/", + }) { + assert_can_parse_consistent(input); + } +} + // Regression: try_can_parse_absolute_fast returned true for a valid IPv4 host // without validating the port. For "wS://1.3.3.51.:+" the host "1.3.3.51." // passes the IPv4 fast path, but the port "+" is not a valid digit, so the From b1c544674ddd160667d543d949655d96d3a4996b Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 10:19:43 -0400 Subject: [PATCH 5/8] fix: drop aggregator freelist to clear IPv4/IPv6 CodSpeed regressions recycle_pooled_buffer showed ~3% self time on Bench_IPv4_Decimal_Aggregator and explained the remaining -3..-5% IP microbench regressions. Restore a default destructor and skip try_parse for digit-led and '[' hosts so pure IP paths match main cold-path cost. --- include/ada/url_aggregator.h | 9 +-------- src/parser.cpp | 18 ++++++++---------- src/url_aggregator.cpp | 29 ----------------------------- 3 files changed, 9 insertions(+), 47 deletions(-) diff --git a/include/ada/url_aggregator.h b/include/ada/url_aggregator.h index 228536b38..f4de97579 100644 --- a/include/ada/url_aggregator.h +++ b/include/ada/url_aggregator.h @@ -49,9 +49,7 @@ struct url_aggregator : url_base { url_aggregator(url_aggregator&& u) noexcept = default; url_aggregator& operator=(url_aggregator&& u) noexcept = default; url_aggregator& operator=(const url_aggregator& u) = default; - // Out-of-line: recycles buffer capacity into a thread-local freelist so the - // next parse on this thread can avoid a heap allocation. - ~url_aggregator() override; + ~url_aggregator() override = default; /** * The setter functions follow the steps defined in the URL Standard. @@ -342,11 +340,6 @@ struct url_aggregator : url_base { url_components components{}; std::string buffer{}; - // Thread-local spare for buffer capacity reuse across parse/destroy. - // Private implementation detail; not part of the public API. - static void adopt_pooled_buffer(std::string& dest, size_t min_capacity); - static void recycle_pooled_buffer(std::string& s) noexcept; - /** * Returns true if neither the search, nor the hash nor the pathname * have been set. diff --git a/src/parser.cpp b/src/parser.cpp index 02201c6e1..5123a00eb 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -476,9 +476,8 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.host_type = DEFAULT; if constexpr (is_aggregator) { - // Pull recycled capacity to avoid malloc on the hot path. const size_t need = need_slash ? len + 1 : len; - url_aggregator::adopt_pooled_buffer(out.buffer, need); + (void)need; if (!need_slash) [[likely]] { string_resize_uninitialized(out.buffer, len); std::memcpy(out.buffer.data(), p, len); @@ -584,18 +583,17 @@ result_type parse_url_impl(std::string_view user_input, } // Simple absolute http(s) fast path (before tabs/newline scan). - // Skip digit-led hosts (typical IPv4) with a cheap peek so pure IPv4 - // microbenches never enter try_parse (matches main; avoids CodSpeed - // regressions on Bench_IPv4_*). + // Skip digit-led hosts (IPv4) and '[' (IPv6) with a cheap peek so pure IP + // microbenches never enter try_parse (matches main + avoids IPv6 call cost). if constexpr (store_values) { if (base_url == nullptr) { const auto* p = reinterpret_cast(user_input.data()); const size_t n = user_input.size(); - const bool digit_led_host = (n >= 8 && p[4] == ':' && p[5] == '/' && - p[6] == '/' && p[7] >= '0' && p[7] <= '9') || - (n >= 9 && p[5] == ':' && p[6] == '/' && - p[7] == '/' && p[8] >= '0' && p[8] <= '9'); - if (!digit_led_host && try_parse_simple_absolute(user_input, url)) + // http://X... at [7] or https://X... at [8] + const uint8_t host0 = + (n >= 9 && p[4] == 's') ? p[8] : (n >= 8 ? p[7] : 0); + const bool skip_simple = (host0 >= '0' && host0 <= '9') || host0 == '['; + if (!skip_simple && try_parse_simple_absolute(user_input, url)) [[likely]] { // need-slash may grow by 1; simple-absolute never expands further. if (user_input.size() + 1 > max_input_length) [[unlikely]] { diff --git a/src/url_aggregator.cpp b/src/url_aggregator.cpp index 2add07b0c..3c5e88dd8 100644 --- a/src/url_aggregator.cpp +++ b/src/url_aggregator.cpp @@ -34,39 +34,10 @@ ada_really_inline void apply_shifted_non_scheme_offsets( } } -// Single thread-local spare buffer. The parse hot path swaps it in (retaining -// capacity) instead of calling the global allocator; the destructor swaps -// capacity back for the next parse on this thread. One spare keeps overhead -// minimal while eliminating malloc/free on the steady-state parse path. -thread_local std::string t_buffer_spare; - } // namespace namespace ada { -void url_aggregator::adopt_pooled_buffer(std::string& dest, - size_t min_capacity) { - // Prefer spare capacity when it is large enough; otherwise grow dest. - if (t_buffer_spare.capacity() >= min_capacity) { - dest.swap(t_buffer_spare); - dest.clear(); - // t_buffer_spare now holds dest's old (usually empty) string. - t_buffer_spare.clear(); - } else if (dest.capacity() < min_capacity) { - dest.reserve(min_capacity); - } -} - -void url_aggregator::recycle_pooled_buffer(std::string& s) noexcept { - // Keep the larger capacity of the two. - if (s.capacity() > t_buffer_spare.capacity()) { - s.clear(); - t_buffer_spare.swap(s); - } -} - -url_aggregator::~url_aggregator() { recycle_pooled_buffer(buffer); } - template [[nodiscard]] ada_really_inline bool url_aggregator::parse_scheme_with_colon( const std::string_view input_with_colon) { From c6c8c0b05e60989eb0f1e20a2c9d773c0cb7c7fb Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 10:25:00 -0400 Subject: [PATCH 6/8] fix: address PR review (host_fast_ok, dead code, IPv4 gating) - Use checkers::is_ipv4 for simple-absolute host gating so domains like example.de / example.be stay on the fast path (not every [0-9a-fx] end). - Same is_ipv4 gate in try_can_parse_clean_http. - Remove unused k_path_bulk / eight_path_bulk / scan_path_bulk_neon. - Keep resize_and_overwrite before Apple __resize_default_init (already). - Freelist already removed (unbounded TLS retention review item). --- src/parser.cpp | 89 +++++-------------------------------------- tests/basic_tests.cpp | 15 ++++++++ 2 files changed, 24 insertions(+), 80 deletions(-) diff --git a/src/parser.cpp b/src/parser.cpp index 5123a00eb..e9579f399 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -86,34 +86,15 @@ constexpr std::array k_host_clean = []() consteval { return t; }(); -// Path bulk: printable ASCII except ? # " < > ` { } ^ \ ' . % -// ('.' and '%' force scalar so path_needs_norm can run). -// 1 = continue bulk, 0 = need scalar handling. -constexpr std::array k_path_bulk = []() consteval { - std::array t{}; - for (uint8_t c = 0x21; c <= 0x7E; ++c) { - t[c] = 1; - } - for (uint8_t c : {static_cast('"'), static_cast('<'), - static_cast('>'), static_cast('`'), - static_cast('{'), static_cast('}'), - static_cast('^'), static_cast('\\'), - static_cast('\''), static_cast('.'), - static_cast('%'), static_cast('?'), - static_cast('#')}) { - t[c] = 0; - } - return t; -}(); - // Grow string to n bytes without requiring value-init of new chars when the // platform provides that API. Not noexcept: allocation may throw bad_alloc. +// Prefer the standard C++23 API; only then the Apple libc++ extension. ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { #if defined(__cpp_lib_string_resize_and_overwrite) s.resize_and_overwrite( n, [](char*, std::size_t count) noexcept { return count; }); #elif defined(_LIBCPP_VERSION) && defined(__APPLE__) - // Apple libc++ public extension; not available on all libc++ / libstdc++. + // Apple libc++ extension; not available on all libc++ / libstdc++. s.__resize_default_init(n); #else s.resize(n); @@ -126,19 +107,12 @@ ada_really_inline bool eight_host_clean(const uint8_t* p) noexcept { k_host_clean[p[6]] & k_host_clean[p[7]]; } -ada_really_inline bool eight_path_bulk(const uint8_t* p) noexcept { - return k_path_bulk[p[0]] & k_path_bulk[p[1]] & k_path_bulk[p[2]] & - k_path_bulk[p[3]] & k_path_bulk[p[4]] & k_path_bulk[p[5]] & - k_path_bulk[p[6]] & k_path_bulk[p[7]]; -} - #if ADA_NEON // Advance over clean lowercase host bytes; returns first non-clean index. ada_really_inline size_t scan_host_clean_neon(const uint8_t* b, size_t start, size_t len) noexcept { size_t i = start; // Accept: a-z 0-9 - . _ ~ - // Range checks via vector compares. for (; i + 16 <= len; i += 16) { const uint8x16_t w = vld1q_u8(b + i); const uint8x16_t ge_a = vcgeq_u8(w, vdupq_n_u8('a')); @@ -156,9 +130,7 @@ ada_really_inline size_t scan_host_clean_neon(const uint8_t* b, size_t start, ok = vorrq_u8(ok, is_dot); ok = vorrq_u8(ok, is_us); ok = vorrq_u8(ok, is_tilde); - // ok lanes are 0xFF if good; find first non-0xFF const uint8x16_t bad = vmvnq_u8(ok); - // Narrow to nibble mask (0x00/0xFF lanes) const uint8x8_t nib = vshrn_n_u16(vreinterpretq_u16_u8(bad), 4); const uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nib), 0); if (bits != 0) { @@ -170,63 +142,20 @@ ada_really_inline size_t scan_host_clean_neon(const uint8_t* b, size_t start, } return i; } - -// Advance over path bulk-ok bytes (no . % ? # or forbidden). -ada_really_inline size_t scan_path_bulk_neon(const uint8_t* b, size_t start, - size_t len) noexcept { - size_t i = start; - for (; i + 16 <= len; i += 16) { - const uint8x16_t w = vld1q_u8(b + i); - // Reject control/space and non-ASCII: c < 0x21 || c > 0x7E - const uint8x16_t ge_21 = vcgeq_u8(w, vdupq_n_u8(0x21)); - const uint8x16_t le_7e = vcleq_u8(w, vdupq_n_u8(0x7e)); - uint8x16_t ok = vandq_u8(ge_21, le_7e); - // Reject " < > ` { } ^ \ ' . % ? # ('.'/'%' exit bulk for norm check) - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('"'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('<'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('>'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('`'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('{'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('}'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('^'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\\'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\''))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('.'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('%'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('?'))); - ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('#'))); - const uint8x16_t bad = vmvnq_u8(ok); - const uint8x8_t nib = vshrn_n_u16(vreinterpretq_u16_u8(bad), 4); - const uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nib), 0); - if (bits != 0) { - return i + (size_t(__builtin_ctzll(bits)) >> 2); - } - } - while (i < len && k_path_bulk[b[i]]) { - ++i; - } - return i; -} #endif // ADA_NEON -// Reject IPv4-like / punycode hosts without a full is_ipv4 scan. -// Fail-closed: mirrors checkers::is_ipv4's cheap last-char filter + xn--. +// Host is acceptable for the simple-absolute fast path (not IPv4, not xn--). +// Domains like "example.de" end in [a-f] but are not IPv4: only leave the +// fast path when checkers::is_ipv4 is true (or punycode was seen). ada_really_inline bool host_fast_ok(const uint8_t* host, size_t n, bool saw_xn) noexcept { if (n == 0 || n > 253 || saw_xn) [[unlikely]] { return false; } - uint8_t last = host[n - 1]; - // Trailing dot: look at previous char (is_ipv4 prunes one trailing dot). - if (last == '.') [[unlikely]] { - if (n == 1) { - return false; - } - last = host[n - 2]; - } - // IPv4 candidates end in digit, a-f, or 'x' (hex/octal forms like "foo.0x"). - if ((last >= '0' && last <= '9') || (last >= 'a' && last <= 'f') || - last == 'x') [[unlikely]] { + const std::string_view hv(reinterpret_cast(host), n); + // is_ipv4 already applies the last-char filter + trailing-dot prune; cheap + // reject for the common non-IPv4 case (including example.de / example.be). + if (checkers::is_ipv4(hv)) [[unlikely]] { return false; } return true; diff --git a/tests/basic_tests.cpp b/tests/basic_tests.cpp index c47bb6302..7f4b1eb31 100644 --- a/tests/basic_tests.cpp +++ b/tests/basic_tests.cpp @@ -1326,6 +1326,21 @@ TYPED_TEST(basic_tests, simple_absolute_fast_path) { // Invalid host (partial IPv4 hex form) - must not be accepted incorrectly. ASSERT_FALSE(url); } + // Domains ending in [a-f] (e.g. .de, .be) are not IPv4: stay valid and + // keep simple-absolute semantics (no credentials/port). + { + auto url = ada::parse("https://example.de/path"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_hostname(), "example.de"); + ASSERT_EQ(url->get_pathname(), "/path"); + ASSERT_EQ(url->get_href(), "https://example.de/path"); + } + { + auto url = ada::parse("http://www.example.be/"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_hostname(), "www.example.be"); + ASSERT_EQ(url->get_href(), "http://www.example.be/"); + } { auto url = ada::parse("http://1.2.3.4"); ASSERT_TRUE(url); From 0a371d6b82c282948597d80f63e856862c8ce667 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 11:50:01 -0400 Subject: [PATCH 7/8] perf: reintroduce bounded string pool and url href cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore a capacity-capped thread-local freelist for URL buffers (24–1024 bytes, 4 slots) so the simple-absolute hot path can avoid malloc without unbounded retention. Extract the pool into string_pool.{h,cpp} and keep destructors inline for ABI. For ada::url, cache the prebuilt href in non_special_scheme on the simple path so get_href is a string copy; materialize path/query/hash on setters and never copy a special-scheme cache via copy_scheme. --- include/ada/string_pool.h | 58 ++++++++++++ include/ada/url-inl.h | 155 +++++++++++++++++++++++++++++-- include/ada/url.h | 34 ++++++- include/ada/url_aggregator-inl.h | 7 ++ include/ada/url_aggregator.h | 4 +- src/ada.cpp | 1 + src/parser.cpp | 70 +++++++++----- src/string_pool.cpp | 107 +++++++++++++++++++++ src/url.cpp | 56 ++++++++++- 9 files changed, 453 insertions(+), 39 deletions(-) create mode 100644 include/ada/string_pool.h create mode 100644 src/string_pool.cpp diff --git a/include/ada/string_pool.h b/include/ada/string_pool.h new file mode 100644 index 000000000..518f7d0d3 --- /dev/null +++ b/include/ada/string_pool.h @@ -0,0 +1,58 @@ +/** + * @file string_pool.h + * @brief Bounded thread-local freelist for `std::string` heap capacity. + * + * @private Not part of the public Ada API; may change at any time. + * + * The parse hot path allocates short-lived URL buffers on every call. This + * pool lets a thread reuse a small number of heap buffers across + * parse/destroy cycles, avoiding malloc/free on the steady-state path while + * keeping retention bounded. + * + * Policy: + * - Only capacities in [kMinCapacity, kMaxCapacity] are retained. + * Below kMinCapacity is typical SSO; recycling would thrash. Above + * kMaxCapacity would retain oversized rare URLs indefinitely. + * - At most kSlotCount buffers are kept per thread. + * - adopt() prefers a recycled buffer that already has enough capacity; + * otherwise it grows the destination with reserve(). + * - recycle() returns capacity to the pool (or replaces a smaller spare). + */ +#ifndef ADA_STRING_POOL_H +#define ADA_STRING_POOL_H + +#include +#include + +namespace ada::string_pool { + +/** Do not retain buffers at or below typical std::string SSO size. */ +inline constexpr size_t kMinCapacity = 24; + +/** Hard cap on retained capacity (bytes). */ +inline constexpr size_t kMaxCapacity = 1024; + +/** Maximum number of spare buffers held per thread. */ +inline constexpr size_t kSlotCount = 4; + +/** + * Ensure @p dest can hold at least @p min_capacity bytes, preferably by + * swapping in a recycled spare (retaining heap capacity without malloc). + * + * On return, @p dest is empty and has capacity >= min_capacity when a spare + * was available or after reserve(). + */ +void adopt(std::string& dest, size_t min_capacity); + +/** + * Offer @p s's heap capacity back to the pool. + * + * Clears and stores @p s when its capacity is within [kMinCapacity, + * kMaxCapacity] and useful to the pool; otherwise leaves @p s alone (its + * destructor will free as usual). Noexcept: never allocates. + */ +void recycle(std::string& s) noexcept; + +} // namespace ada::string_pool + +#endif // ADA_STRING_POOL_H diff --git a/include/ada/url-inl.h b/include/ada/url-inl.h index ad54d9366..6fcbdf17c 100644 --- a/include/ada/url-inl.h +++ b/include/ada/url-inl.h @@ -7,6 +7,8 @@ #include "ada/url.h" #include "ada/url_components.h" +#include "ada/helpers.h" +#include "ada/string_pool.h" #include #include @@ -17,6 +19,16 @@ #endif // ADA_REGULAR_VISUAL_STUDIO namespace ada { + +// Inline destructor: recycles freelist capacity without an out-of-line public +// symbol flip (Agents.md ABI: no non-inline↔inline public method changes). +inline url::~url() { + // Host/query/hash are typically SSO-sized; path and the simple-absolute + // href cache (non_special_scheme) may hold heap capacity. + string_pool::recycle(path); + string_pool::recycle(non_special_scheme); +} + [[nodiscard]] ada_really_inline bool url::has_credentials() const noexcept { return !username.empty() || !password.empty(); } @@ -39,16 +51,115 @@ inline std::ostream& operator<<(std::ostream& out, const ada::url& u) { return out << u.to_string(); } +// True when non_special_scheme holds a simple-absolute href cache for a +// special scheme (the field is otherwise empty for special URLs). +[[nodiscard]] inline bool url::has_simple_href_cache() const noexcept { + return !non_special_scheme.empty() && + type != ada::scheme::type::NOT_SPECIAL; +} + +inline void url::clear_simple_href_cache() noexcept { + if (type != ada::scheme::type::NOT_SPECIAL) { + non_special_scheme.clear(); + } +} + +// Materialize path/query/hash from the href cache, then drop the cache. +// Called by setters before mutating so components stay consistent. +inline void url::materialize_from_simple_href_cache() { + if (!has_simple_href_cache()) { + return; + } + const std::string& href = non_special_scheme; + const size_t auth = (type == ada::scheme::type::HTTPS) ? 8 : 7; + const size_t host_len = host.has_value() ? host->size() : 0; + const size_t path_begin = auth + host_len; + size_t path_end = href.size(); + size_t q_pos = std::string::npos; + size_t h_pos = std::string::npos; + if (path_begin < href.size()) { + q_pos = href.find('?', path_begin); + h_pos = href.find('#', path_begin); + path_end = href.size(); + if (q_pos != std::string::npos) { + path_end = q_pos; + } + if (h_pos != std::string::npos && h_pos < path_end) { + path_end = h_pos; + } + path.assign(href.data() + path_begin, path_end - path_begin); + } else { + path = "/"; + } + if (q_pos != std::string::npos) { + const size_t q_end = + (h_pos != std::string::npos && h_pos > q_pos) ? h_pos : href.size(); + query.emplace(href.data() + q_pos + 1, q_end - q_pos - 1); + } + if (h_pos != std::string::npos) { + hash.emplace(href.data() + h_pos + 1, href.size() - h_pos - 1); + } + non_special_scheme.clear(); +} + +[[nodiscard]] inline std::string_view url::simple_href_path() const noexcept { + const size_t auth = (type == ada::scheme::type::HTTPS) ? 8 : 7; + const size_t host_len = host.has_value() ? host->size() : 0; + const size_t path_begin = auth + host_len; + if (path_begin >= non_special_scheme.size()) { + return "/"; + } + const size_t path_end = + non_special_scheme.find_first_of("?#", path_begin); + if (path_end == std::string::npos) { + return std::string_view(non_special_scheme).substr(path_begin); + } + return std::string_view(non_special_scheme) + .substr(path_begin, path_end - path_begin); +} + [[nodiscard]] size_t url::get_pathname_length() const noexcept { + if (has_simple_href_cache() && path.empty()) { + return simple_href_path().size(); + } return path.size(); } -[[nodiscard]] constexpr std::string_view url::get_pathname() const noexcept { +[[nodiscard]] inline std::string_view url::get_pathname() const noexcept { + if (has_simple_href_cache() && path.empty()) { + return simple_href_path(); + } return path; } [[nodiscard]] ada_really_inline ada::url_components url::get_components() const { + // Simple-absolute href cache: offsets match the prebuilt href layout. + if (has_simple_href_cache()) { + url_components out{}; + const uint32_t protocol_end = + (type == ada::scheme::type::HTTPS) ? 6u : 5u; + out.protocol_end = protocol_end; + out.username_end = protocol_end + 2; + out.host_start = protocol_end + 2; + const uint32_t host_len = + host.has_value() ? uint32_t(host->size()) : 0u; + // Match the non-credentials branch below: host_end is last host index. + out.host_end = out.host_start + host_len - (host_len > 0 ? 1u : 0u); + out.port = url_components::omitted; + const size_t path_begin = size_t(protocol_end) + 2 + host_len; + out.pathname_start = uint32_t(path_begin); + const size_t q = non_special_scheme.find('?', path_begin); + const size_t h = non_special_scheme.find('#', path_begin); + if (q != std::string::npos && (h == std::string::npos || q < h)) { + out.search_start = uint32_t(q); + } + if (h != std::string::npos) { + out.hash_start = uint32_t(h); + } + return out; + } + url_components out{}; // protocol ends with ':'. for example: "https:" @@ -157,12 +268,24 @@ constexpr void url::clear_pathname() { path.clear(); } constexpr void url::clear_search() { query = std::nullopt; } -[[nodiscard]] constexpr bool url::has_hash() const noexcept { - return hash.has_value(); +[[nodiscard]] inline bool url::has_hash() const noexcept { + if (hash.has_value()) { + return true; + } + if (has_simple_href_cache()) { + return non_special_scheme.find('#') != std::string::npos; + } + return false; } -[[nodiscard]] constexpr bool url::has_search() const noexcept { - return query.has_value(); +[[nodiscard]] inline bool url::has_search() const noexcept { + if (query.has_value()) { + return true; + } + if (has_simple_href_cache()) { + return non_special_scheme.find('?') != std::string::npos; + } + return false; } constexpr void url::set_protocol_as_file() { type = ada::scheme::type::FILE; } @@ -176,13 +299,23 @@ inline void url::set_scheme(std::string&& new_scheme) noexcept { } constexpr void url::copy_scheme(ada::url&& u) { - non_special_scheme = u.non_special_scheme; type = u.type; + // non_special_scheme holds the scheme name only for non-special URLs. For + // special URLs it may hold a simple-absolute href cache — never copy that. + if (u.type == ada::scheme::type::NOT_SPECIAL) { + non_special_scheme = std::move(u.non_special_scheme); + } else { + non_special_scheme.clear(); + } } constexpr void url::copy_scheme(const ada::url& u) { - non_special_scheme = u.non_special_scheme; type = u.type; + if (u.type == ada::scheme::type::NOT_SPECIAL) { + non_special_scheme = u.non_special_scheme; + } else { + non_special_scheme.clear(); + } } namespace detail { @@ -202,6 +335,11 @@ ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { } // namespace detail [[nodiscard]] ada_really_inline std::string url::get_href() const { + // Simple-absolute cache: return a copy of the prebuilt href. (Stealing would + // break a second get_href / getters that slice the cache.) + if (has_simple_href_cache()) [[likely]] { + return non_special_scheme; + } // Hot path: special URL, no credentials, no port (covers almost all // benchdata / production absolute URLs). if (host.has_value() && username.empty() && password.empty() && @@ -308,6 +446,9 @@ ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { } [[nodiscard]] inline size_t url::get_href_size() const noexcept { + if (has_simple_href_cache()) { + return non_special_scheme.size(); + } size_t size = 0; if (is_special()) { size += ada::scheme::details::is_special_list[type].size() + 1; diff --git a/include/ada/url.h b/include/ada/url.h index 8b50e7a05..b93c6eb1e 100644 --- a/include/ada/url.h +++ b/include/ada/url.h @@ -65,7 +65,9 @@ struct url : url_base { url(url&& u) noexcept = default; url& operator=(url&& u) noexcept = default; url& operator=(const url& u) = default; - ~url() override = default; + // Inline (see url-inl.h): recycles path / href-cache capacity into a bounded + // thread-local freelist. Kept inline to match main's defaulted dtor ABI. + ~url() override; // Fields are ordered so that the most frequently accessed components // tend to occupy earlier cache lines and remain close together in memory. @@ -123,6 +125,30 @@ struct url : url_base { */ std::string password{}; + /** + * @private + * True when non_special_scheme holds a simple-absolute href cache. + */ + [[nodiscard]] inline bool has_simple_href_cache() const noexcept; + + /** + * @private + * Drop the simple-absolute href cache after mutation so get_href rebuilds. + */ + inline void clear_simple_href_cache() noexcept; + + /** + * @private + * Expand path/query/hash from the href cache, then clear the cache. + */ + inline void materialize_from_simple_href_cache(); + + /** + * @private + * Pathname slice of the simple-absolute href cache. + */ + [[nodiscard]] inline std::string_view simple_href_path() const noexcept; + /** * Checks if the URL has an empty hostname (host is set but empty string). * @return `true` if host exists but is empty, `false` otherwise. @@ -204,7 +230,7 @@ struct url : url_base { * @return A string_view pointing to the path. * @see https://url.spec.whatwg.org/#dom-url-pathname */ - [[nodiscard]] constexpr std::string_view get_pathname() const noexcept; + [[nodiscard]] std::string_view get_pathname() const noexcept; /** * Returns the byte length of the pathname without creating a string. @@ -360,13 +386,13 @@ struct url : url_base { * Checks if the URL has a fragment/hash component. * @return `true` if hash is present, `false` otherwise. */ - [[nodiscard]] constexpr bool has_hash() const noexcept override; + [[nodiscard]] bool has_hash() const noexcept override; /** * Checks if the URL has a query/search component. * @return `true` if query is present, `false` otherwise. */ - [[nodiscard]] constexpr bool has_search() const noexcept override; + [[nodiscard]] bool has_search() const noexcept override; private: friend ada::url ada::parser::parse_url(std::string_view, diff --git a/include/ada/url_aggregator-inl.h b/include/ada/url_aggregator-inl.h index 7f70bcd30..26ff913f3 100644 --- a/include/ada/url_aggregator-inl.h +++ b/include/ada/url_aggregator-inl.h @@ -7,6 +7,7 @@ #include "ada/character_sets-inl.h" #include "ada/helpers.h" +#include "ada/string_pool.h" #include "ada/unicode-inl.h" #include "ada/url_aggregator.h" #include "ada/url_components.h" @@ -19,6 +20,12 @@ namespace ada { +// Inline destructor: recycles freelist capacity without an out-of-line public +// symbol flip (Agents.md ABI: no non-inline↔inline public method changes). +inline url_aggregator::~url_aggregator() { + string_pool::recycle(buffer); +} + inline void url_aggregator::update_base_authority( std::string_view base_buffer, const ada::url_components& base) { std::string_view input = base_buffer.substr( diff --git a/include/ada/url_aggregator.h b/include/ada/url_aggregator.h index f4de97579..365844d4c 100644 --- a/include/ada/url_aggregator.h +++ b/include/ada/url_aggregator.h @@ -49,7 +49,9 @@ struct url_aggregator : url_base { url_aggregator(url_aggregator&& u) noexcept = default; url_aggregator& operator=(url_aggregator&& u) noexcept = default; url_aggregator& operator=(const url_aggregator& u) = default; - ~url_aggregator() override = default; + // Inline (see url_aggregator-inl.h): recycles buffer capacity into a bounded + // thread-local freelist. Kept inline to match main's defaulted dtor ABI. + ~url_aggregator() override; /** * The setter functions follow the steps defined in the URL Standard. diff --git a/src/ada.cpp b/src/ada.cpp index 321dbef16..fb0d82956 100644 --- a/src/ada.cpp +++ b/src/ada.cpp @@ -4,6 +4,7 @@ #include "serializers.cpp" #include "implementation.cpp" #include "helpers.cpp" +#include "string_pool.cpp" #include "url.cpp" #include "parser.cpp" #include "url_components.cpp" diff --git a/src/parser.cpp b/src/parser.cpp index e9579f399..84a285da0 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -13,6 +13,7 @@ #include "ada/scheme-inl.h" #include "ada/unicode-inl.h" #include "ada/unicode.h" +#include "ada/string_pool.h" #include "ada/url.h" #include "ada/url_aggregator.h" #include "ada/url_aggregator-inl.h" @@ -210,6 +211,24 @@ constexpr std::array k_rest_ok = []() consteval { // Single-pass: all of [start,end) allowed for copy-as-is (no encoding). // Path ./% for path_needs_norm is detected separately via memchr on the path. +// Copy path and query from a base url, resolving a possible simple-absolute +// href cache (path/query fields may be empty while getters still return data). +ada_really_inline void copy_url_path_and_query_from_base(url& dest, + const url& base) { + dest.path = std::string(base.get_pathname()); + if (base.has_search()) { + const std::string s = base.get_search(); + // get_search() returns "" for empty query ("?"), or "?value". + if (s.empty() || s == "?") { + dest.query = ""; + } else { + dest.query = s.substr(1); + } + } else { + dest.query = std::nullopt; + } +} + ada_really_inline bool rest_is_clean(const uint8_t* b, size_t start, size_t end) noexcept { size_t i = start; @@ -405,8 +424,9 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.host_type = DEFAULT; if constexpr (is_aggregator) { + // Pull recycled capacity to avoid malloc on the hot path (see string_pool). const size_t need = need_slash ? len + 1 : len; - (void)need; + string_pool::adopt(out.buffer, need); if (!need_slash) [[likely]] { string_resize_uninitialized(out.buffer, len); std::memcpy(out.buffer.data(), p, len); @@ -450,23 +470,32 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, : url_components::omitted; } } else { - out.host.emplace(p + host_start, host_len); - if (has_upper) [[unlikely]] { - unicode::to_lower_ascii(out.host->data(), out.host->size()); - } - if (need_slash) { - out.path = "/"; + // Single heap string: full href in non_special_scheme + SSO host. + // path/query/hash stay empty; getters slice the cache. Setters call + // materialize_from_simple_href_cache() first (see url.cpp). + const size_t href_len = need_slash ? len + 1 : len; + string_pool::adopt(out.non_special_scheme, href_len); + if (!need_slash) [[likely]] { + string_resize_uninitialized(out.non_special_scheme, len); + std::memcpy(out.non_special_scheme.data(), p, len); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.non_special_scheme.data() + host_start, + host_len); + } } else { - out.path.assign(p + path_start, path_end - path_start); - } - if (query_start != std::string_view::npos) { - const size_t q_end = - hash_start != std::string_view::npos ? hash_start : len; - out.query.emplace(p + query_start + 1, q_end - query_start - 1); - } - if (hash_start != std::string_view::npos) { - out.hash.emplace(p + hash_start + 1, len - hash_start - 1); + string_resize_uninitialized(out.non_special_scheme, len + 1); + std::memcpy(out.non_special_scheme.data(), p, host_end); + out.non_special_scheme[host_end] = '/'; + if (host_end < len) { + std::memcpy(out.non_special_scheme.data() + host_end + 1, p + host_end, + len - host_end); + } + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.non_special_scheme.data() + host_start, + host_len); + } } + out.host.emplace(out.non_special_scheme.data() + host_start, host_len); } return true; } @@ -701,8 +730,7 @@ result_type parse_url_impl(std::string_view user_input, url.has_opaque_path = base_url->has_opaque_path; if constexpr (result_type_is_ada_url) { - url.path = base_url->path; - url.query = base_url->query; + copy_url_path_and_query_from_base(url, *base_url); } else { url.update_base_pathname(base_url->get_pathname()); if (base_url->has_search()) { @@ -926,8 +954,7 @@ result_type parse_url_impl(std::string_view user_input, url.port = base_url->port; // cloning the base path includes cloning the has_opaque_path flag url.has_opaque_path = base_url->has_opaque_path; - url.path = base_url->path; - url.query = base_url->query; + copy_url_path_and_query_from_base(url, *base_url); } else { url.update_base_authority(base_url->get_href(), base_url->get_components()); @@ -1380,8 +1407,7 @@ result_type parse_url_impl(std::string_view user_input, ada_log("FILE base non-null"); if constexpr (result_type_is_ada_url) { url.host = base_url->host; - url.path = base_url->path; - url.query = base_url->query; + copy_url_path_and_query_from_base(url, *base_url); } else { url.update_host_to_base_host(base_url->get_hostname()); url.update_base_pathname(base_url->get_pathname()); diff --git a/src/string_pool.cpp b/src/string_pool.cpp new file mode 100644 index 000000000..e413155a1 --- /dev/null +++ b/src/string_pool.cpp @@ -0,0 +1,107 @@ +/** + * @file string_pool.cpp + * @brief Implementation of the bounded thread-local string freelist. + */ + +#include "ada/string_pool.h" + +#include +#include + +namespace ada::string_pool { +namespace { + +/** + * Per-thread spare list. Kept in this TU so the freelist is a single + * instance shared by the library (not duplicated across translation units). + */ +struct freelist { + std::string slots[kSlotCount]; + uint8_t count{0}; +}; + +freelist& thread_freelist() { + thread_local freelist list; + return list; +} + +/** True when capacity is worth keeping in the pool. */ +constexpr bool is_retainable(size_t capacity) noexcept { + return capacity >= kMinCapacity && capacity <= kMaxCapacity; +} + +/** + * Remove slot @p idx by swapping with the last live slot and clearing it. + * Precondition: idx < list.count. + */ +void erase_slot(freelist& list, uint8_t idx) { + const uint8_t last = static_cast(list.count - 1); + if (idx != last) { + list.slots[idx].swap(list.slots[last]); + } + list.slots[last].clear(); + list.count = last; +} + +/** Index of the spare with the smallest capacity (list must be full). */ +uint8_t index_of_smallest(const freelist& list) noexcept { + uint8_t min_i = 0; + size_t min_c = list.slots[0].capacity(); + for (uint8_t i = 1; i < kSlotCount; ++i) { + const size_t c = list.slots[i].capacity(); + if (c < min_c) { + min_c = c; + min_i = i; + } + } + return min_i; +} + +} // namespace + +void adopt(std::string& dest, size_t min_capacity) { + freelist& list = thread_freelist(); + + // Most recently recycled spares are at the end; try those first so a + // steady-state parse size tends to hit immediately. + for (uint8_t i = list.count; i > 0; --i) { + const uint8_t idx = static_cast(i - 1); + if (list.slots[idx].capacity() >= min_capacity) { + dest.swap(list.slots[idx]); + dest.clear(); + // list.slots[idx] now holds dest's prior (usually empty) string. + erase_slot(list, idx); + return; + } + } + + if (dest.capacity() < min_capacity) { + dest.reserve(min_capacity); + } +} + +void recycle(std::string& s) noexcept { + const size_t cap = s.capacity(); + if (!is_retainable(cap)) { + return; + } + + freelist& list = thread_freelist(); + + if (list.count < kSlotCount) { + s.clear(); + list.slots[list.count].swap(s); + ++list.count; + return; + } + + // Pool full: keep the larger capacity, drop the smaller spare. + const uint8_t min_i = index_of_smallest(list); + if (cap <= list.slots[min_i].capacity()) { + return; + } + s.clear(); + list.slots[min_i].swap(s); +} + +} // namespace ada::string_pool diff --git a/src/url.cpp b/src/url.cpp index ccda46035..0fe752451 100644 --- a/src/url.cpp +++ b/src/url.cpp @@ -484,21 +484,36 @@ ada_really_inline void url::parse_path(std::string_view input) { answer.append("\",\n"); } answer.append("\t\"path\":\""); - helpers::encode_json(path, back); + // Use getter so a simple-absolute href cache (path field empty) is visible. + helpers::encode_json(get_pathname(), back); answer.append("\",\n"); answer.append("\t\"opaque path\":"); answer.append((has_opaque_path ? "true" : "false")); if (has_search()) { answer.append(",\n"); answer.append("\t\"query\":\""); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - helpers::encode_json(query.value(), back); + if (query.has_value()) { + helpers::encode_json(query.value(), back); + } else if (has_simple_href_cache()) { + // Empty or non-empty query only in the href cache. + const std::string s = get_search(); + if (s.size() > 1) { + helpers::encode_json(s.substr(1), back); + } + } answer.append("\""); } - if (hash.has_value()) { + if (has_hash()) { answer.append(",\n"); answer.append("\t\"hash\":\""); - helpers::encode_json(hash.value(), back); + if (hash.has_value()) { + helpers::encode_json(hash.value(), back); + } else if (has_simple_href_cache()) { + const std::string s = get_hash(); + if (s.size() > 1) { + helpers::encode_json(s.substr(1), back); + } + } answer.append("\""); } answer.append("\n}"); @@ -565,6 +580,21 @@ ada_really_inline void url::parse_path(std::string_view input) { } [[nodiscard]] std::string url::get_search() const { + if (has_simple_href_cache() && !query.has_value()) { + const size_t q = non_special_scheme.find('?'); + if (q == std::string::npos) { + return ""; + } + const size_t h = non_special_scheme.find('#', q + 1); + // Empty query ("?") returns "" like the materialized path. + if (h == q + 1 || (h == std::string::npos && q + 1 == non_special_scheme.size())) { + return ""; + } + if (h == std::string::npos) { + return non_special_scheme.substr(q); + } + return non_special_scheme.substr(q, h - q); + } // If this's URL's query is either null or the empty string, then return the // empty string. Return U+003F (?), followed by this's URL's query. return (!query.has_value() || (query->empty())) ? "" : "?" + query.value(); @@ -583,6 +613,14 @@ ada_really_inline void url::parse_path(std::string_view input) { } [[nodiscard]] std::string url::get_hash() const { + if (has_simple_href_cache() && !hash.has_value()) { + const size_t h = non_special_scheme.find('#'); + // Match the materialized path: empty fragment returns "" not "#". + if (h == std::string::npos || h + 1 >= non_special_scheme.size()) { + return ""; + } + return non_special_scheme.substr(h); + } // If this's URL's fragment is either null or the empty string, then return // the empty string. Return U+0023 (#), followed by this's URL's fragment. return (!hash.has_value() || (hash->empty())) ? "" : "#" + hash.value(); @@ -594,6 +632,7 @@ bool url::set_host_or_hostname(const std::string_view input) { return false; } + materialize_from_simple_href_cache(); url saved_url(*this); size_t host_end_pos = input.find('#'); @@ -718,6 +757,7 @@ bool url::set_username(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } + materialize_from_simple_href_cache(); auto previous_username = std::move(username); username = ada::unicode::percent_encode( input, character_sets::USERINFO_PERCENT_ENCODE); @@ -732,6 +772,7 @@ bool url::set_password(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } + materialize_from_simple_href_cache(); auto previous_password = std::move(password); password = ada::unicode::percent_encode( input, character_sets::USERINFO_PERCENT_ENCODE); @@ -746,6 +787,7 @@ bool url::set_port(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } + materialize_from_simple_href_cache(); if (input.empty()) { port = std::nullopt; @@ -786,6 +828,7 @@ bool url::set_port(const std::string_view input) { } void url::set_hash(const std::string_view input) { + materialize_from_simple_href_cache(); if (input.empty()) { hash = std::nullopt; helpers::strip_trailing_spaces_from_opaque_path(*this); @@ -804,6 +847,7 @@ void url::set_hash(const std::string_view input) { } void url::set_search(const std::string_view input) { + materialize_from_simple_href_cache(); if (input.empty()) { query = std::nullopt; helpers::strip_trailing_spaces_from_opaque_path(*this); @@ -829,6 +873,7 @@ bool url::set_pathname(const std::string_view input) { if (has_opaque_path) { return false; } + materialize_from_simple_href_cache(); auto previous_path = std::move(path); path.clear(); parse_path(input); @@ -840,6 +885,7 @@ bool url::set_pathname(const std::string_view input) { } bool url::set_protocol(const std::string_view input) { + materialize_from_simple_href_cache(); std::string view(input); helpers::remove_ascii_tab_or_newline(view); if (view.empty()) { From 0cae1871d9977b4f32bcf4e8fe0a69b63e06a5b7 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 12:46:00 -0400 Subject: [PATCH 8/8] fix: drop aggregator freelist and simplify href cache Restore default url_aggregator destructor and stop pooling its buffer: recycling on every destroy regressed CodSpeed IPv4 aggregator (~3-5%). Keep a single-spare string pool only for the url simple-absolute href cache. Fill path/query/hash at parse time so lazy materialize/getter special cases can go. Replace non-ASCII punctuation for just_ascii. --- include/ada/string_pool.h | 38 ++------- include/ada/url-inl.h | 130 +++---------------------------- include/ada/url.h | 18 +---- include/ada/url_aggregator-inl.h | 7 -- include/ada/url_aggregator.h | 4 +- src/parser.cpp | 55 +++++++------ src/string_pool.cpp | 87 +++------------------ src/url.cpp | 64 ++++----------- 8 files changed, 72 insertions(+), 331 deletions(-) diff --git a/include/ada/string_pool.h b/include/ada/string_pool.h index 518f7d0d3..a4bd4942b 100644 --- a/include/ada/string_pool.h +++ b/include/ada/string_pool.h @@ -1,22 +1,11 @@ /** * @file string_pool.h - * @brief Bounded thread-local freelist for `std::string` heap capacity. + * @brief Single thread-local spare for `std::string` heap capacity. * * @private Not part of the public Ada API; may change at any time. * - * The parse hot path allocates short-lived URL buffers on every call. This - * pool lets a thread reuse a small number of heap buffers across - * parse/destroy cycles, avoiding malloc/free on the steady-state path while - * keeping retention bounded. - * - * Policy: - * - Only capacities in [kMinCapacity, kMaxCapacity] are retained. - * Below kMinCapacity is typical SSO; recycling would thrash. Above - * kMaxCapacity would retain oversized rare URLs indefinitely. - * - At most kSlotCount buffers are kept per thread. - * - adopt() prefers a recycled buffer that already has enough capacity; - * otherwise it grows the destination with reserve(). - * - recycle() returns capacity to the pool (or replaces a smaller spare). + * One spare buffer per thread. Capacity is retained only when it is above + * typical SSO and at most kMaxCapacity, so memory use stays bounded. */ #ifndef ADA_STRING_POOL_H #define ADA_STRING_POOL_H @@ -26,31 +15,16 @@ namespace ada::string_pool { -/** Do not retain buffers at or below typical std::string SSO size. */ +/** Do not retain SSO-sized buffers. */ inline constexpr size_t kMinCapacity = 24; /** Hard cap on retained capacity (bytes). */ inline constexpr size_t kMaxCapacity = 1024; -/** Maximum number of spare buffers held per thread. */ -inline constexpr size_t kSlotCount = 4; - -/** - * Ensure @p dest can hold at least @p min_capacity bytes, preferably by - * swapping in a recycled spare (retaining heap capacity without malloc). - * - * On return, @p dest is empty and has capacity >= min_capacity when a spare - * was available or after reserve(). - */ +/** Prefer a recycled spare with capacity >= min_capacity; else reserve. */ void adopt(std::string& dest, size_t min_capacity); -/** - * Offer @p s's heap capacity back to the pool. - * - * Clears and stores @p s when its capacity is within [kMinCapacity, - * kMaxCapacity] and useful to the pool; otherwise leaves @p s alone (its - * destructor will free as usual). Noexcept: never allocates. - */ +/** Return capacity to the spare when within [kMinCapacity, kMaxCapacity]. */ void recycle(std::string& s) noexcept; } // namespace ada::string_pool diff --git a/include/ada/url-inl.h b/include/ada/url-inl.h index 6fcbdf17c..d4ddff968 100644 --- a/include/ada/url-inl.h +++ b/include/ada/url-inl.h @@ -20,14 +20,8 @@ namespace ada { -// Inline destructor: recycles freelist capacity without an out-of-line public -// symbol flip (Agents.md ABI: no non-inline↔inline public method changes). -inline url::~url() { - // Host/query/hash are typically SSO-sized; path and the simple-absolute - // href cache (non_special_scheme) may hold heap capacity. - string_pool::recycle(path); - string_pool::recycle(non_special_scheme); -} +// Inline destructor: recycle href-cache capacity (see string_pool). +inline url::~url() { string_pool::recycle(non_special_scheme); } [[nodiscard]] ada_really_inline bool url::has_credentials() const noexcept { return !username.empty() || !password.empty(); @@ -51,11 +45,10 @@ inline std::ostream& operator<<(std::ostream& out, const ada::url& u) { return out << u.to_string(); } -// True when non_special_scheme holds a simple-absolute href cache for a -// special scheme (the field is otherwise empty for special URLs). +// non_special_scheme holds a full href only on the simple-absolute path for +// special schemes (otherwise empty or a non-special scheme name). [[nodiscard]] inline bool url::has_simple_href_cache() const noexcept { - return !non_special_scheme.empty() && - type != ada::scheme::type::NOT_SPECIAL; + return !non_special_scheme.empty() && type != ada::scheme::type::NOT_SPECIAL; } inline void url::clear_simple_href_cache() noexcept { @@ -64,102 +57,16 @@ inline void url::clear_simple_href_cache() noexcept { } } -// Materialize path/query/hash from the href cache, then drop the cache. -// Called by setters before mutating so components stay consistent. -inline void url::materialize_from_simple_href_cache() { - if (!has_simple_href_cache()) { - return; - } - const std::string& href = non_special_scheme; - const size_t auth = (type == ada::scheme::type::HTTPS) ? 8 : 7; - const size_t host_len = host.has_value() ? host->size() : 0; - const size_t path_begin = auth + host_len; - size_t path_end = href.size(); - size_t q_pos = std::string::npos; - size_t h_pos = std::string::npos; - if (path_begin < href.size()) { - q_pos = href.find('?', path_begin); - h_pos = href.find('#', path_begin); - path_end = href.size(); - if (q_pos != std::string::npos) { - path_end = q_pos; - } - if (h_pos != std::string::npos && h_pos < path_end) { - path_end = h_pos; - } - path.assign(href.data() + path_begin, path_end - path_begin); - } else { - path = "/"; - } - if (q_pos != std::string::npos) { - const size_t q_end = - (h_pos != std::string::npos && h_pos > q_pos) ? h_pos : href.size(); - query.emplace(href.data() + q_pos + 1, q_end - q_pos - 1); - } - if (h_pos != std::string::npos) { - hash.emplace(href.data() + h_pos + 1, href.size() - h_pos - 1); - } - non_special_scheme.clear(); -} - -[[nodiscard]] inline std::string_view url::simple_href_path() const noexcept { - const size_t auth = (type == ada::scheme::type::HTTPS) ? 8 : 7; - const size_t host_len = host.has_value() ? host->size() : 0; - const size_t path_begin = auth + host_len; - if (path_begin >= non_special_scheme.size()) { - return "/"; - } - const size_t path_end = - non_special_scheme.find_first_of("?#", path_begin); - if (path_end == std::string::npos) { - return std::string_view(non_special_scheme).substr(path_begin); - } - return std::string_view(non_special_scheme) - .substr(path_begin, path_end - path_begin); -} - [[nodiscard]] size_t url::get_pathname_length() const noexcept { - if (has_simple_href_cache() && path.empty()) { - return simple_href_path().size(); - } return path.size(); } -[[nodiscard]] inline std::string_view url::get_pathname() const noexcept { - if (has_simple_href_cache() && path.empty()) { - return simple_href_path(); - } +[[nodiscard]] constexpr std::string_view url::get_pathname() const noexcept { return path; } [[nodiscard]] ada_really_inline ada::url_components url::get_components() const { - // Simple-absolute href cache: offsets match the prebuilt href layout. - if (has_simple_href_cache()) { - url_components out{}; - const uint32_t protocol_end = - (type == ada::scheme::type::HTTPS) ? 6u : 5u; - out.protocol_end = protocol_end; - out.username_end = protocol_end + 2; - out.host_start = protocol_end + 2; - const uint32_t host_len = - host.has_value() ? uint32_t(host->size()) : 0u; - // Match the non-credentials branch below: host_end is last host index. - out.host_end = out.host_start + host_len - (host_len > 0 ? 1u : 0u); - out.port = url_components::omitted; - const size_t path_begin = size_t(protocol_end) + 2 + host_len; - out.pathname_start = uint32_t(path_begin); - const size_t q = non_special_scheme.find('?', path_begin); - const size_t h = non_special_scheme.find('#', path_begin); - if (q != std::string::npos && (h == std::string::npos || q < h)) { - out.search_start = uint32_t(q); - } - if (h != std::string::npos) { - out.hash_start = uint32_t(h); - } - return out; - } - url_components out{}; // protocol ends with ':'. for example: "https:" @@ -268,24 +175,12 @@ constexpr void url::clear_pathname() { path.clear(); } constexpr void url::clear_search() { query = std::nullopt; } -[[nodiscard]] inline bool url::has_hash() const noexcept { - if (hash.has_value()) { - return true; - } - if (has_simple_href_cache()) { - return non_special_scheme.find('#') != std::string::npos; - } - return false; +[[nodiscard]] constexpr bool url::has_hash() const noexcept { + return hash.has_value(); } -[[nodiscard]] inline bool url::has_search() const noexcept { - if (query.has_value()) { - return true; - } - if (has_simple_href_cache()) { - return non_special_scheme.find('?') != std::string::npos; - } - return false; +[[nodiscard]] constexpr bool url::has_search() const noexcept { + return query.has_value(); } constexpr void url::set_protocol_as_file() { type = ada::scheme::type::FILE; } @@ -301,7 +196,7 @@ inline void url::set_scheme(std::string&& new_scheme) noexcept { constexpr void url::copy_scheme(ada::url&& u) { type = u.type; // non_special_scheme holds the scheme name only for non-special URLs. For - // special URLs it may hold a simple-absolute href cache — never copy that. + // special URLs it may hold a simple-absolute href cache - never copy that. if (u.type == ada::scheme::type::NOT_SPECIAL) { non_special_scheme = std::move(u.non_special_scheme); } else { @@ -335,8 +230,7 @@ ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { } // namespace detail [[nodiscard]] ada_really_inline std::string url::get_href() const { - // Simple-absolute cache: return a copy of the prebuilt href. (Stealing would - // break a second get_href / getters that slice the cache.) + // Simple-absolute path prebuilds the full href in non_special_scheme. if (has_simple_href_cache()) [[likely]] { return non_special_scheme; } diff --git a/include/ada/url.h b/include/ada/url.h index b93c6eb1e..53b083733 100644 --- a/include/ada/url.h +++ b/include/ada/url.h @@ -137,18 +137,6 @@ struct url : url_base { */ inline void clear_simple_href_cache() noexcept; - /** - * @private - * Expand path/query/hash from the href cache, then clear the cache. - */ - inline void materialize_from_simple_href_cache(); - - /** - * @private - * Pathname slice of the simple-absolute href cache. - */ - [[nodiscard]] inline std::string_view simple_href_path() const noexcept; - /** * Checks if the URL has an empty hostname (host is set but empty string). * @return `true` if host exists but is empty, `false` otherwise. @@ -230,7 +218,7 @@ struct url : url_base { * @return A string_view pointing to the path. * @see https://url.spec.whatwg.org/#dom-url-pathname */ - [[nodiscard]] std::string_view get_pathname() const noexcept; + [[nodiscard]] constexpr std::string_view get_pathname() const noexcept; /** * Returns the byte length of the pathname without creating a string. @@ -386,13 +374,13 @@ struct url : url_base { * Checks if the URL has a fragment/hash component. * @return `true` if hash is present, `false` otherwise. */ - [[nodiscard]] bool has_hash() const noexcept override; + [[nodiscard]] constexpr bool has_hash() const noexcept override; /** * Checks if the URL has a query/search component. * @return `true` if query is present, `false` otherwise. */ - [[nodiscard]] bool has_search() const noexcept override; + [[nodiscard]] constexpr bool has_search() const noexcept override; private: friend ada::url ada::parser::parse_url(std::string_view, diff --git a/include/ada/url_aggregator-inl.h b/include/ada/url_aggregator-inl.h index 26ff913f3..7f70bcd30 100644 --- a/include/ada/url_aggregator-inl.h +++ b/include/ada/url_aggregator-inl.h @@ -7,7 +7,6 @@ #include "ada/character_sets-inl.h" #include "ada/helpers.h" -#include "ada/string_pool.h" #include "ada/unicode-inl.h" #include "ada/url_aggregator.h" #include "ada/url_components.h" @@ -20,12 +19,6 @@ namespace ada { -// Inline destructor: recycles freelist capacity without an out-of-line public -// symbol flip (Agents.md ABI: no non-inline↔inline public method changes). -inline url_aggregator::~url_aggregator() { - string_pool::recycle(buffer); -} - inline void url_aggregator::update_base_authority( std::string_view base_buffer, const ada::url_components& base) { std::string_view input = base_buffer.substr( diff --git a/include/ada/url_aggregator.h b/include/ada/url_aggregator.h index 365844d4c..f4de97579 100644 --- a/include/ada/url_aggregator.h +++ b/include/ada/url_aggregator.h @@ -49,9 +49,7 @@ struct url_aggregator : url_base { url_aggregator(url_aggregator&& u) noexcept = default; url_aggregator& operator=(url_aggregator&& u) noexcept = default; url_aggregator& operator=(const url_aggregator& u) = default; - // Inline (see url_aggregator-inl.h): recycles buffer capacity into a bounded - // thread-local freelist. Kept inline to match main's defaulted dtor ABI. - ~url_aggregator() override; + ~url_aggregator() override = default; /** * The setter functions follow the steps defined in the URL Standard. diff --git a/src/parser.cpp b/src/parser.cpp index 84a285da0..6597b4077 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -211,24 +211,6 @@ constexpr std::array k_rest_ok = []() consteval { // Single-pass: all of [start,end) allowed for copy-as-is (no encoding). // Path ./% for path_needs_norm is detected separately via memchr on the path. -// Copy path and query from a base url, resolving a possible simple-absolute -// href cache (path/query fields may be empty while getters still return data). -ada_really_inline void copy_url_path_and_query_from_base(url& dest, - const url& base) { - dest.path = std::string(base.get_pathname()); - if (base.has_search()) { - const std::string s = base.get_search(); - // get_search() returns "" for empty query ("?"), or "?value". - if (s.empty() || s == "?") { - dest.query = ""; - } else { - dest.query = s.substr(1); - } - } else { - dest.query = std::nullopt; - } -} - ada_really_inline bool rest_is_clean(const uint8_t* b, size_t start, size_t end) noexcept { size_t i = start; @@ -424,9 +406,8 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.host_type = DEFAULT; if constexpr (is_aggregator) { - // Pull recycled capacity to avoid malloc on the hot path (see string_pool). - const size_t need = need_slash ? len + 1 : len; - string_pool::adopt(out.buffer, need); + // No freelist: recycling on every destroy regressed IPv4 aggregator + // CodSpeed (~3-5%). Buffer is short-lived and filled in one shot. if (!need_slash) [[likely]] { string_resize_uninitialized(out.buffer, len); std::memcpy(out.buffer.data(), p, len); @@ -470,9 +451,8 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, : url_components::omitted; } } else { - // Single heap string: full href in non_special_scheme + SSO host. - // path/query/hash stay empty; getters slice the cache. Setters call - // materialize_from_simple_href_cache() first (see url.cpp). + // Prebuild href for get_href(); also fill host/path/query/hash so getters + // and setters use normal fields. Setters clear the href cache. const size_t href_len = need_slash ? len + 1 : len; string_pool::adopt(out.non_special_scheme, href_len); if (!need_slash) [[likely]] { @@ -495,7 +475,23 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, host_len); } } - out.host.emplace(out.non_special_scheme.data() + host_start, host_len); + const char* href = out.non_special_scheme.data(); + out.host.emplace(href + host_start, host_len); + if (need_slash) { + out.path = "/"; + } else { + out.path.assign(href + path_start, path_end - path_start); + } + if (query_start != std::string_view::npos) { + const size_t q_end = + hash_start != std::string_view::npos ? hash_start : len; + const size_t q_off = need_slash ? query_start + 1 : query_start; + out.query.emplace(href + q_off + 1, q_end - query_start - 1); + } + if (hash_start != std::string_view::npos) { + const size_t h_off = need_slash ? hash_start + 1 : hash_start; + out.hash.emplace(href + h_off + 1, len - hash_start - 1); + } } return true; } @@ -730,7 +726,8 @@ result_type parse_url_impl(std::string_view user_input, url.has_opaque_path = base_url->has_opaque_path; if constexpr (result_type_is_ada_url) { - copy_url_path_and_query_from_base(url, *base_url); + url.path = base_url->path; + url.query = base_url->query; } else { url.update_base_pathname(base_url->get_pathname()); if (base_url->has_search()) { @@ -954,7 +951,8 @@ result_type parse_url_impl(std::string_view user_input, url.port = base_url->port; // cloning the base path includes cloning the has_opaque_path flag url.has_opaque_path = base_url->has_opaque_path; - copy_url_path_and_query_from_base(url, *base_url); + url.path = base_url->path; + url.query = base_url->query; } else { url.update_base_authority(base_url->get_href(), base_url->get_components()); @@ -1407,7 +1405,8 @@ result_type parse_url_impl(std::string_view user_input, ada_log("FILE base non-null"); if constexpr (result_type_is_ada_url) { url.host = base_url->host; - copy_url_path_and_query_from_base(url, *base_url); + url.path = base_url->path; + url.query = base_url->query; } else { url.update_host_to_base_host(base_url->get_hostname()); url.update_base_pathname(base_url->get_pathname()); diff --git a/src/string_pool.cpp b/src/string_pool.cpp index e413155a1..bca762bc8 100644 --- a/src/string_pool.cpp +++ b/src/string_pool.cpp @@ -1,80 +1,28 @@ /** * @file string_pool.cpp - * @brief Implementation of the bounded thread-local string freelist. + * @brief Single thread-local string spare (bounded freelist of size 1). */ #include "ada/string_pool.h" -#include -#include - namespace ada::string_pool { namespace { -/** - * Per-thread spare list. Kept in this TU so the freelist is a single - * instance shared by the library (not duplicated across translation units). - */ -struct freelist { - std::string slots[kSlotCount]; - uint8_t count{0}; -}; - -freelist& thread_freelist() { - thread_local freelist list; - return list; -} +thread_local std::string t_spare; -/** True when capacity is worth keeping in the pool. */ -constexpr bool is_retainable(size_t capacity) noexcept { +bool retainable(size_t capacity) noexcept { return capacity >= kMinCapacity && capacity <= kMaxCapacity; } -/** - * Remove slot @p idx by swapping with the last live slot and clearing it. - * Precondition: idx < list.count. - */ -void erase_slot(freelist& list, uint8_t idx) { - const uint8_t last = static_cast(list.count - 1); - if (idx != last) { - list.slots[idx].swap(list.slots[last]); - } - list.slots[last].clear(); - list.count = last; -} - -/** Index of the spare with the smallest capacity (list must be full). */ -uint8_t index_of_smallest(const freelist& list) noexcept { - uint8_t min_i = 0; - size_t min_c = list.slots[0].capacity(); - for (uint8_t i = 1; i < kSlotCount; ++i) { - const size_t c = list.slots[i].capacity(); - if (c < min_c) { - min_c = c; - min_i = i; - } - } - return min_i; -} - } // namespace void adopt(std::string& dest, size_t min_capacity) { - freelist& list = thread_freelist(); - - // Most recently recycled spares are at the end; try those first so a - // steady-state parse size tends to hit immediately. - for (uint8_t i = list.count; i > 0; --i) { - const uint8_t idx = static_cast(i - 1); - if (list.slots[idx].capacity() >= min_capacity) { - dest.swap(list.slots[idx]); - dest.clear(); - // list.slots[idx] now holds dest's prior (usually empty) string. - erase_slot(list, idx); - return; - } + if (t_spare.capacity() >= min_capacity) { + dest.swap(t_spare); + dest.clear(); + t_spare.clear(); + return; } - if (dest.capacity() < min_capacity) { dest.reserve(min_capacity); } @@ -82,26 +30,11 @@ void adopt(std::string& dest, size_t min_capacity) { void recycle(std::string& s) noexcept { const size_t cap = s.capacity(); - if (!is_retainable(cap)) { - return; - } - - freelist& list = thread_freelist(); - - if (list.count < kSlotCount) { - s.clear(); - list.slots[list.count].swap(s); - ++list.count; - return; - } - - // Pool full: keep the larger capacity, drop the smaller spare. - const uint8_t min_i = index_of_smallest(list); - if (cap <= list.slots[min_i].capacity()) { + if (!retainable(cap) || cap <= t_spare.capacity()) { return; } s.clear(); - list.slots[min_i].swap(s); + t_spare.swap(s); } } // namespace ada::string_pool diff --git a/src/url.cpp b/src/url.cpp index 0fe752451..ba8210999 100644 --- a/src/url.cpp +++ b/src/url.cpp @@ -484,36 +484,21 @@ ada_really_inline void url::parse_path(std::string_view input) { answer.append("\",\n"); } answer.append("\t\"path\":\""); - // Use getter so a simple-absolute href cache (path field empty) is visible. - helpers::encode_json(get_pathname(), back); + helpers::encode_json(path, back); answer.append("\",\n"); answer.append("\t\"opaque path\":"); answer.append((has_opaque_path ? "true" : "false")); if (has_search()) { answer.append(",\n"); answer.append("\t\"query\":\""); - if (query.has_value()) { - helpers::encode_json(query.value(), back); - } else if (has_simple_href_cache()) { - // Empty or non-empty query only in the href cache. - const std::string s = get_search(); - if (s.size() > 1) { - helpers::encode_json(s.substr(1), back); - } - } + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + helpers::encode_json(query.value(), back); answer.append("\""); } - if (has_hash()) { + if (hash.has_value()) { answer.append(",\n"); answer.append("\t\"hash\":\""); - if (hash.has_value()) { - helpers::encode_json(hash.value(), back); - } else if (has_simple_href_cache()) { - const std::string s = get_hash(); - if (s.size() > 1) { - helpers::encode_json(s.substr(1), back); - } - } + helpers::encode_json(hash.value(), back); answer.append("\""); } answer.append("\n}"); @@ -580,21 +565,6 @@ ada_really_inline void url::parse_path(std::string_view input) { } [[nodiscard]] std::string url::get_search() const { - if (has_simple_href_cache() && !query.has_value()) { - const size_t q = non_special_scheme.find('?'); - if (q == std::string::npos) { - return ""; - } - const size_t h = non_special_scheme.find('#', q + 1); - // Empty query ("?") returns "" like the materialized path. - if (h == q + 1 || (h == std::string::npos && q + 1 == non_special_scheme.size())) { - return ""; - } - if (h == std::string::npos) { - return non_special_scheme.substr(q); - } - return non_special_scheme.substr(q, h - q); - } // If this's URL's query is either null or the empty string, then return the // empty string. Return U+003F (?), followed by this's URL's query. return (!query.has_value() || (query->empty())) ? "" : "?" + query.value(); @@ -613,14 +583,6 @@ ada_really_inline void url::parse_path(std::string_view input) { } [[nodiscard]] std::string url::get_hash() const { - if (has_simple_href_cache() && !hash.has_value()) { - const size_t h = non_special_scheme.find('#'); - // Match the materialized path: empty fragment returns "" not "#". - if (h == std::string::npos || h + 1 >= non_special_scheme.size()) { - return ""; - } - return non_special_scheme.substr(h); - } // If this's URL's fragment is either null or the empty string, then return // the empty string. Return U+0023 (#), followed by this's URL's fragment. return (!hash.has_value() || (hash->empty())) ? "" : "#" + hash.value(); @@ -632,7 +594,7 @@ bool url::set_host_or_hostname(const std::string_view input) { return false; } - materialize_from_simple_href_cache(); + clear_simple_href_cache(); url saved_url(*this); size_t host_end_pos = input.find('#'); @@ -757,7 +719,7 @@ bool url::set_username(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } - materialize_from_simple_href_cache(); + clear_simple_href_cache(); auto previous_username = std::move(username); username = ada::unicode::percent_encode( input, character_sets::USERINFO_PERCENT_ENCODE); @@ -772,7 +734,7 @@ bool url::set_password(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } - materialize_from_simple_href_cache(); + clear_simple_href_cache(); auto previous_password = std::move(password); password = ada::unicode::percent_encode( input, character_sets::USERINFO_PERCENT_ENCODE); @@ -787,7 +749,7 @@ bool url::set_port(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } - materialize_from_simple_href_cache(); + clear_simple_href_cache(); if (input.empty()) { port = std::nullopt; @@ -828,7 +790,7 @@ bool url::set_port(const std::string_view input) { } void url::set_hash(const std::string_view input) { - materialize_from_simple_href_cache(); + clear_simple_href_cache(); if (input.empty()) { hash = std::nullopt; helpers::strip_trailing_spaces_from_opaque_path(*this); @@ -847,7 +809,7 @@ void url::set_hash(const std::string_view input) { } void url::set_search(const std::string_view input) { - materialize_from_simple_href_cache(); + clear_simple_href_cache(); if (input.empty()) { query = std::nullopt; helpers::strip_trailing_spaces_from_opaque_path(*this); @@ -873,7 +835,7 @@ bool url::set_pathname(const std::string_view input) { if (has_opaque_path) { return false; } - materialize_from_simple_href_cache(); + clear_simple_href_cache(); auto previous_path = std::move(path); path.clear(); parse_path(input); @@ -885,7 +847,7 @@ bool url::set_pathname(const std::string_view input) { } bool url::set_protocol(const std::string_view input) { - materialize_from_simple_href_cache(); + clear_simple_href_cache(); std::string view(input); helpers::remove_ascii_tab_or_newline(view); if (view.empty()) {