From 7461360c1505a9efd8deba2d0adc6eda13626c57 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Fri, 4 Sep 2026 13:40:47 -0400 Subject: [PATCH 1/3] utf8: add an ASCII fast path to utf8_is_printable_newline() This decoded every character twice: utf8_encoded_valid_unichar() decodes internally to do its validation and then throws the codepoint away, and the caller immediately called utf8_encoded_to_unichar() again to get the very same codepoint back. Fix that where it originates rather than at the call site: add utf8_encoded_valid_unichar_full(), which optionally returns the character it had to decode anyway, and make utf8_encoded_valid_unichar() a wrapper that passes NULL. Callers that want both the validity and the value now decode once. On top of that add a bulk fast path, since almost everything we are asked about is plain printable ASCII: test eight bytes at a time for that, i.e. that no byte is >= 0x80, none is < 0x20 and none is 0x7f. A remainder of less than eight bytes is copied into a word padded with spaces so that it can be tested the same way. A word that does not qualify falls through to the per character path, so tabs, newlines and multibyte sequences are handled exactly as before. The function sits on journalctl's per-field output path, so this is measurable: on a 730M journal spread over 100 files, journalctl -o export goes from 4.02s to about 2.9s. Also extend the tests, which so far covered neither DEL nor the C1 range nor allow_newline=false: place each of the interesting bytes at each offset within the eight byte word, and check a multibyte character straddling the end of one. The new code was verified to agree with the old one on 41945090 inputs, for both values of allow_newline: every byte string of length zero to three over all 256 byte values, four million random strings over uniform bytes, over an ASCII-heavy alphabet and over an alphabet of encoding boundary bytes, and a corpus placing every byte value at every offset of buffers of every length from eight to twenty-four bytes, with valid and truncated multibyte sequences straddling the word boundary. --- src/basic/utf8.c | 64 +++++++++++++++++++++++++++++++++++++++----- src/basic/utf8.h | 5 +++- src/test/test-utf8.c | 35 ++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/basic/utf8.c b/src/basic/utf8.c index c4298cfe76..7cd0d74871 100644 --- a/src/basic/utf8.c +++ b/src/basic/utf8.c @@ -11,6 +11,7 @@ #include "gunicode.h" #include "hexdecoct.h" #include "string-util.h" +#include "unaligned.h" #include "utf8.h" bool unichar_is_valid(char32_t ch) { @@ -102,6 +103,20 @@ int utf8_encoded_to_unichar(const char *str, char32_t *ret_unichar) { return len; } +/* Tests whether all eight bytes of a word are plain printable ASCII, i.e. within 0x20…0x7e. The three + * terms below cover the three ways a byte can fail. A byte that fails is always flagged in its own + * lane, but the converse does not hold: the two subtractions are whole-word ones, so a borrow out of + * a failing lane also flags bytes above it. Only the aggregate test is meaningful, the mask is not a + * per-byte map of which bytes failed. */ +static bool is_printable_fastpath_u64(uint64_t w) { + uint64_t x = w ^ UINT64_C(0x7f7f7f7f7f7f7f7f); + + return (((w & UINT64_C(0x8080808080808080)) | /* >= 0x80 */ + ((w - UINT64_C(0x2020202020202020)) & ~w) | /* < 0x20 */ + ((x - UINT64_C(0x0101010101010101)) & ~x)) & /* == 0x7f */ + UINT64_C(0x8080808080808080)) == 0; +} + bool utf8_is_printable_newline(const char* str, size_t length, bool allow_newline) { assert(str); @@ -109,14 +124,41 @@ bool utf8_is_printable_newline(const char* str, size_t length, bool allow_newlin int encoded_len; char32_t val; - encoded_len = utf8_encoded_valid_unichar(p, length); + /* Bulk fast path: almost everything we are asked about is plain printable ASCII, so + * test eight bytes of it at once. Anything else falls through to the per-character + * path below, which handles it as before. A remainder of less than eight bytes is + * copied into a word padded with spaces, so that it can be tested the same way. */ + while (length > 0) { + uint64_t w; + size_t n; + + if (length < sizeof(uint64_t)) { + n = length; + w = UINT64_C(0x2020202020202020); + memcpy(&w, p, n); + } else { + n = sizeof(uint64_t); + w = unaligned_read_ne64(p); + } + + if (!is_printable_fastpath_u64(w)) + break; + + length -= n; + p += n; + } + + /* Note that this is not redundant with the loop condition above: the fast path may + * have consumed the remainder of the string, and then there's nothing left to decode. */ + if (length == 0) + break; + + encoded_len = utf8_encoded_valid_unichar_full(p, length, &val); if (encoded_len < 0) return false; assert(encoded_len > 0 && (size_t) encoded_len <= length); - if (utf8_encoded_to_unichar(p, &val) < 0 || - unichar_is_control(val) || - (!allow_newline && val == '\n')) + if (unichar_is_control(val) || (!allow_newline && val == '\n')) return false; length -= encoded_len; @@ -541,7 +583,7 @@ static int utf8_unichar_to_encoded_len(char32_t unichar) { } /* validate one encoded unicode char and return its length */ -int utf8_encoded_valid_unichar(const char *str, size_t length /* bytes */) { +int utf8_encoded_valid_unichar_full(const char *str, size_t length /* bytes */, char32_t *ret_unichar) { char32_t unichar; size_t len; int r; @@ -549,7 +591,9 @@ int utf8_encoded_valid_unichar(const char *str, size_t length /* bytes */) { assert(str); assert(length > 0); - /* We read until NUL, at most length bytes. SIZE_MAX may be used to disable the length check. */ + /* We read until NUL, at most length bytes. SIZE_MAX may be used to disable the length check. + * If ret_unichar is non-NULL the decoded character is returned in it, so that callers + * interested in both validity and value needn't decode the character a second time. */ len = utf8_encoded_expected_len(str[0]); if (len == 0) @@ -560,8 +604,11 @@ int utf8_encoded_valid_unichar(const char *str, size_t length /* bytes */) { return -EINVAL; /* ascii is valid */ - if (len == 1) + if (len == 1) { + if (ret_unichar) + *ret_unichar = (char32_t) (uint8_t) str[0]; return 1; + } /* check if expected encoded chars are available */ for (size_t i = 0; i < len; i++) @@ -580,6 +627,9 @@ int utf8_encoded_valid_unichar(const char *str, size_t length /* bytes */) { if (!unichar_is_valid(unichar)) return -EINVAL; + if (ret_unichar) + *ret_unichar = unichar; + return (int) len; } diff --git a/src/basic/utf8.h b/src/basic/utf8.h index fde72a0d54..19dca605f7 100644 --- a/src/basic/utf8.h +++ b/src/basic/utf8.h @@ -38,7 +38,10 @@ char16_t *utf8_to_utf16(const char *s, size_t length); size_t char16_strlen(const char16_t *s) _pure_; /* returns the number of 16-bit words in the string (not bytes!) */ size_t char16_strsize(const char16_t *s) _pure_; -int utf8_encoded_valid_unichar(const char *str, size_t length) _pure_; +int utf8_encoded_valid_unichar_full(const char *str, size_t length, char32_t *ret_unichar); +static inline int _pure_ utf8_encoded_valid_unichar(const char *str, size_t length) { + return utf8_encoded_valid_unichar_full(str, length, NULL); +} int utf8_encoded_to_unichar(const char *str, char32_t *ret_unichar); static inline bool utf16_is_surrogate(char16_t c) { diff --git a/src/test/test-utf8.c b/src/test/test-utf8.c index 875700fa7b..c6071d2cdd 100644 --- a/src/test/test-utf8.c +++ b/src/test/test-utf8.c @@ -7,6 +7,13 @@ #include "utf8.h" TEST(utf8_is_printable) { + /* Control bytes, plus the exact values the fast path's masks turn on: the last byte below + * 0x20 and the first at it, the last byte at or below 0x7e and the first above it, and the + * first byte with the topmost bit set. */ + static const char interesting[] = { + '\r', '\177', '\001', '\033', '\t', '\n', '\037', '\040', '\176', '\200', + }; + assert_se(utf8_is_printable("ascii is valid\tunicode", 22)); assert_se(utf8_is_printable("\342\204\242", 3)); assert_se(!utf8_is_printable("\341\204", 2)); @@ -14,6 +21,34 @@ TEST(utf8_is_printable) { assert_se(!utf8_is_printable("\r", 1)); assert_se(utf8_is_printable("\n", 1)); assert_se(utf8_is_printable("\t", 1)); + ASSERT_TRUE(utf8_is_printable("", 0)); + + /* DEL and the C1 range */ + ASSERT_FALSE(utf8_is_printable("\177", 1)); + ASSERT_FALSE(utf8_is_printable("\302\233", 2)); + + /* Lengths around the eight byte word the fast path works on: an exact multiple of it, one + * short of it, and a multi-byte character straddling its end. */ + ASSERT_TRUE(utf8_is_printable("abcdefgh", 8)); + ASSERT_TRUE(utf8_is_printable("abcdefg", 7)); + ASSERT_TRUE(utf8_is_printable("aaaaaaa\342\204\242aaaaa", 15)); + ASSERT_FALSE(utf8_is_printable("aaaaaaa\342\204", 9)); + + /* Put each of the interesting bytes at each offset of a longer string, so that a fast path + * that mishandles a single byte position doesn't go unnoticed. */ + char buf[16]; + FOREACH_ELEMENT(c, interesting) + for (size_t i = 0; i < sizeof(buf); i++) { + memset(buf, 'a', sizeof(buf)); + buf[i] = *c; + + /* Note that 0x80 on its own is not a valid encoding either, so it is + * expected to be rejected just like the control bytes. */ + bool printable = (uint8_t) *c >= 0x20 && (uint8_t) *c <= 0x7e; + + ASSERT_EQ(utf8_is_printable(buf, sizeof(buf)), printable || IN_SET(*c, '\t', '\n')); + ASSERT_EQ(utf8_is_printable_newline(buf, sizeof(buf), false), printable || *c == '\t'); + } } TEST(utf8_n_is_valid) { From 79e32e7c731f1b40cedef188dde0316656d08cfd Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Fri, 4 Sep 2026 13:40:47 -0400 Subject: [PATCH 2/3] ratelimit,sd-journal: don't read the clock once per journal file per entry sd_journal_next() sweeps every open journal file to find the next entry, and next_beyond_location() rate-limits its tail timestamp refresh per file. Since ratelimit_below() reads the clock itself, iterating a journal spread over a hundred files meant a hundred clock_gettime() calls for every single entry, nearly all of them only to conclude that the one second interval had not elapsed yet. On a 730M journal in 100 files this was the single largest cost in journalctl, over a fifth of the run. Add ratelimit_below_at(), which takes the timestamp from the caller, and read the clock once per sweep in real_journal_next() instead. ratelimit_below() keeps its behaviour and becomes a wrapper. The timestamp is now sampled once for the whole sweep rather than once per file. The interval being rate-limited is a second and a sweep takes microseconds, so this makes no practical difference. On that journal, journalctl -o export goes from 4.02s to 2.93s, and the default journalctl -o short from 3.30s to 2.14s. --- src/basic/ratelimit.c | 16 +++++++++++----- src/basic/ratelimit.h | 5 +++++ src/libsystemd/sd-journal/sd-journal.c | 10 +++++++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/basic/ratelimit.c b/src/basic/ratelimit.c index b4ef5470b0..9a0ec05c43 100644 --- a/src/basic/ratelimit.c +++ b/src/basic/ratelimit.c @@ -6,16 +6,12 @@ /* Modelled after Linux' lib/ratelimit.c by Dave Young * , which is licensed GPLv2. */ -bool ratelimit_below(RateLimit *rl) { - usec_t ts; - +bool ratelimit_below_at(RateLimit *rl, usec_t ts) { assert(rl); if (!ratelimit_configured(rl)) return true; - ts = now(CLOCK_BOOTTIME); - if (rl->begin <= 0 || usec_sub_unsigned(ts, rl->begin) > rl->interval) { rl->begin = ts; /* Start a new time window */ @@ -30,6 +26,16 @@ bool ratelimit_below(RateLimit *rl) { return rl->num <= rl->burst; } +bool ratelimit_below(RateLimit *rl) { + assert(rl); + + if (!ratelimit_configured(rl)) /* Keep this check here too: the argument below is evaluated + * before the callee could short-circuit on it. */ + return true; + + return ratelimit_below_at(rl, now(CLOCK_BOOTTIME)); +} + unsigned ratelimit_num_dropped(const RateLimit *rl) { assert(rl); diff --git a/src/basic/ratelimit.h b/src/basic/ratelimit.h index c70571340a..d5cce82efd 100644 --- a/src/basic/ratelimit.h +++ b/src/basic/ratelimit.h @@ -21,6 +21,11 @@ static inline bool ratelimit_configured(const RateLimit *rl) { } bool ratelimit_below(RateLimit *rl); +/* Like ratelimit_below(), but takes the current CLOCK_BOOTTIME timestamp from the caller, for callers + * that check many rate limits in a row and should not read the clock for each of them. The timestamps + * passed for a given RateLimit must not go backwards: a ts before the one that opened the current + * window makes usec_sub_unsigned() return 0, i.e. the window looks like it just started. */ +bool ratelimit_below_at(RateLimit *rl, usec_t ts); unsigned ratelimit_num_dropped(const RateLimit *rl); diff --git a/src/libsystemd/sd-journal/sd-journal.c b/src/libsystemd/sd-journal/sd-journal.c index 3208ce9733..e0f742005b 100644 --- a/src/libsystemd/sd-journal/sd-journal.c +++ b/src/libsystemd/sd-journal/sd-journal.c @@ -996,7 +996,7 @@ static int next_with_matches( direction, ret, ret_offset); } -static int next_beyond_location(sd_journal *j, JournalFile *f, direction_t direction) { +static int next_beyond_location(sd_journal *j, JournalFile *f, direction_t direction, usec_t ts) { Object *c; uint64_t cp, n_entries; int r; @@ -1008,7 +1008,7 @@ static int next_beyond_location(sd_journal *j, JournalFile *f, direction_t direc * O(N x files) volatile mmap overhead that makes large 'journalctl -n N' queries unusably * slow. Periodic refresh keeps cross-boot ordering reasonably fresh and provides a fallback * for any missed inotify events. */ - if (ratelimit_below(&f->tail_timestamp_ratelimit)) + if (ratelimit_below_at(&f->tail_timestamp_ratelimit, ts)) (void) journal_file_read_tail_timestamp(j, f); n_entries = le64toh(f->header->n_entries); @@ -1134,6 +1134,10 @@ static int real_journal_next(sd_journal *j, direction_t direction) { assert_return(j, -EINVAL); assert_return(!journal_origin_changed(j), -ECHILD); + /* Read the clock once for the whole call: next_beyond_location() rate-limits its tail timestamp + * refresh, which would otherwise be one clock read per file for every entry. */ + usec_t ts = now(CLOCK_BOOTTIME); + for (;;) { JournalFile *new_file = NULL, *exact_match = NULL; Object *o; @@ -1146,7 +1150,7 @@ static int real_journal_next(sd_journal *j, direction_t direction) { JournalFile *f = (JournalFile*) *_f; bool found; - r = next_beyond_location(j, f, direction); + r = next_beyond_location(j, f, direction, ts); if (r < 0) { log_debug_errno(r, "Can't iterate through %s, ignoring: %m", f->path); remove_file_real(j, f); From 8adaac7f295a2d42bf1a16c712d51b413e16e61a Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Fri, 4 Sep 2026 13:40:47 -0400 Subject: [PATCH 3/3] logs-show: pre-size the per-entry JSON field hashmap journal_entry_to_json() builds a fresh hashmap for every entry and then fills it with that entry's fields, of which there are typically around twenty. Since the hashmap starts out empty it has to grow several times per entry, and every growth rehashes everything inserted so far. journalctl -o json spent about a quarter of its time in siphash, a third of that under resize_buckets(). Reserve room up front. On a 730M journal spread over 100 files, journalctl -o json -n 200000 goes from 5.71s to 4.64s. --- src/shared/logs-show.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/shared/logs-show.c b/src/shared/logs-show.c index 513d76558e..ce41d2d87a 100644 --- a/src/shared/logs-show.c +++ b/src/shared/logs-show.c @@ -1254,6 +1254,12 @@ int journal_entry_to_json( if (!h) return log_oom(); + /* A typical entry has around twenty fields and this hashmap is built fresh for each one, so + * size it up front rather than rehashing repeatedly while filling it. */ + r = hashmap_reserve(h, 32); + if (r < 0) + return log_oom(); + r = update_json_data(h, flags, "__CURSOR", cursor, SIZE_MAX); if (r < 0) return r;