Propagate the ASCII string tag across concatenation

Each concatenation produced a fresh, untagged string, so a loop of
`s += x` with an accompanying sizeof(s) re-derived the tag from scratch
for every intermediate -- O(n^2) in scanning on top of the O(n^2) the
copying already costs.

All three concat macros now carry the tag through:
ascii(a + b) == ascii(a) && ascii(b), computed from the operands before
the result is built. That identity is exact only because the predicate
excludes CR: with no CR on either side no CR-LF (one cluster, UAX #29
GB3) can form across the seam. It is exact in the NO direction too --
a non-ASCII operand's bytes survive into the result -- so neither case
ever rescans the joined string.

The third macro, SVALUE_STRING_ADD_LEFT (f_add's int/float/object +
string paths), needs the same treatment as the two string+string ones.
Note the operand there is a raw stack buffer rather than an svalue, so
it is queried with counted=false: it has no block header to read a
cached tag out of, and treating it as though it did would be a wild
read.

Pinned by regression tests that tag "a\r" and "\nb" separately and then
join them: a propagated "both ASCII" would report 4 clusters where the
CRLF makes 3. Covered on both the string+string path and the
<non-string>+string one, the latter with the left operand held in a
variable so the expression cannot be constant-folded before f_add runs.
Verified to FAIL (2 checks) with the propagation forced to "ASCII".

  append+sizeof loop, 4000 iters   875,913 ns -> 548,144 ns

The remainder is the string building itself, which is inherently
quadratic in copying.

Validated: LPC testsuite 3x, 334/334 GTest, Debug+ASan/UBSan with the
DEBUGMALLOC ref-count checker, RelWithDebInfo+ASan/UBSan -- all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MN9sz4nvGgBZw9oZiei3XR
This commit is contained in:
Claude 2026-08-07 11:16:05 +00:00
parent 547ee319da
commit f98d09bab7
No known key found for this signature in database
2 changed files with 53 additions and 0 deletions

View file

@ -157,6 +157,23 @@ inline void free_svalue_maybe_refed(svalue_t* v) {
// commonly used svalue.
extern svalue_t const0, const1, const0u;
/* Is this string svalue pure ASCII (and CR-free), i.e. byte offset == grapheme
* cluster index? Kept as a macro rather than an inline function so it expands
* where SVALUE_STRLEN/STRING_COUNTED are already visible. */
#define SVALUE_STR_ASCII(sv) \
u8_string_is_ascii_cached((sv)->u.string, SVALUE_STRLEN(sv), ((sv)->subtype & STRING_COUNTED) != 0)
/* Tag a freshly built concatenation. ascii(a + b) == ascii(a) && ascii(b) is
* exact ONLY because the ASCII predicate excludes CR: with no CR on either
* side, no CR-LF (one cluster, UAX #29 GB3) can form across the seam. If
* either side is non-ASCII its bytes survive into the result, so NO likewise
* propagates exactly -- neither case needs to rescan the joined string.
*
* Without this, `s += x` in a loop re-derives the tag from scratch for each
* intermediate string, making an accompanying sizeof(s) O(n^2). */
#define MSTR_TAG_JOIN(res, ascii_both) \
(MSTR_ASCII(res) = (ascii_both) ? MSTR_ASCII_YES : MSTR_ASCII_NO)
/* These are not used anywhere */
/* Beek - add some sanity to joining strings */
@ -169,6 +186,7 @@ extern svalue_t const0, const1, const0u;
int ess_r; \
ess_len = (ess_r = SVALUE_STRLEN(x)) + strlen(y); \
if (ess_len > max_string_length) error("Maximum string length exceeded in concatenation.\n"); \
bool ess_ascii = SVALUE_STR_ASCII(x) && u8_string_is_ascii_cached((y), ess_len - ess_r, false);\
if ((x)->subtype == STRING_MALLOC && MSTR_REF((x)->u.string) == 1) { \
ess_res = (char*)extend_string((x)->u.string, ess_len); \
if (!ess_res) fatal("Out of memory!\n"); \
@ -181,6 +199,7 @@ extern svalue_t const0, const1, const0u;
(x)->subtype = STRING_MALLOC; \
} \
(x)->u.string = ess_res; \
MSTR_TAG_JOIN(ess_res, ess_ascii); \
})
/* <something that needs no free> + string svalue */
@ -192,6 +211,7 @@ extern svalue_t const0, const1, const0u;
int pss_len; \
pss_len = SVALUE_STRLEN(sp) + (pss_r = strlen(y)); \
if (pss_len > max_string_length) error("Maximum string length exceeded in concatenation.\n"); \
bool pss_ascii = SVALUE_STR_ASCII(sp) && u8_string_is_ascii_cached((y), pss_r, false); \
pss_res = new_string(pss_len, z); \
strcpy(pss_res, y); \
strcpy(pss_res + pss_r, sp->u.string); \
@ -199,6 +219,7 @@ extern svalue_t const0, const1, const0u;
sp->type = T_STRING; \
sp->u.string = pss_res; \
sp->subtype = STRING_MALLOC; \
MSTR_TAG_JOIN(pss_res, pss_ascii); \
})
/* basically, string + string; faster than using extend b/c of SVALUE_STRLEN */
@ -211,6 +232,7 @@ extern svalue_t const0, const1, const0u;
ssj_r = SVALUE_STRLEN(x); \
ssj_len = ssj_r + SVALUE_STRLEN(y); \
if (ssj_len > max_string_length) error("Maximum string length exceeded in concatenation.\n"); \
bool ssj_ascii = SVALUE_STR_ASCII(x) && SVALUE_STR_ASCII(y); \
if ((x)->subtype == STRING_MALLOC && MSTR_REF((x)->u.string) == 1) { \
ssj_res = (char*)extend_string((x)->u.string, ssj_len); \
if (!ssj_res) fatal("Out of memory!\n"); \
@ -225,6 +247,7 @@ extern svalue_t const0, const1, const0u;
(x)->subtype = STRING_MALLOC; \
} \
(x)->u.string = ssj_res; \
MSTR_TAG_JOIN(ssj_res, ssj_ascii); \
})
// Translate svalue into json summary, only suitable for

View file

@ -49,6 +49,36 @@ void do_tests() {
s += wide; // grows in place
ASSERT_EQ(7, sizeof(s)); // 5 + 2 clusters, NOT 5 + 6 bytes
// --- the CR-LF seam: concatenation propagates the tag as
// ascii(a+b) == ascii(a) && ascii(b), which is only exact because a CR
// disqualifies a string outright. Joining "...\r" with "\n..." forms one
// CRLF cluster ACROSS the seam, so a propagated "both ASCII" would be a
// wrong answer here -- both sides must already be tagged non-ASCII. ---
s = "a\r";
ASSERT_EQ(2, sizeof(s)); // tag it before joining
s += "\nb";
ASSERT_EQ(3, sizeof(s)); // 'a', CRLF, 'b' -- not 4
// --- <non-string> + string takes a THIRD concat path in f_add
// (SVALUE_STRING_ADD_LEFT, for int/float/object on the left), which builds
// the result fresh instead of extending either operand. The left operand is
// held in a variable so the expression cannot be constant-folded away before
// f_add ever runs.
//
// Cluster counts here are correct whether or not the tag is propagated -- an
// untagged result just gets rescanned. What this pins is the direction that
// would NOT be self-correcting: propagating "ASCII" across a seam that forms
// a CRLF, which would answer from the byte length and be wrong. ---
int n = 12;
s = n + "abc";
ASSERT_EQ("12abc", s);
ASSERT_EQ(5, sizeof(s));
s = n + wide; // non-ASCII on the right must not come back tagged ASCII
ASSERT_EQ(2 + sizeof(wide), sizeof(s));
n = 1;
s = n + "\r\nb"; // '1', CRLF, 'b' -- a propagated "ASCII" would say 4
ASSERT_EQ(3, sizeof(s));
// --- non-ASCII, then reduced back to ASCII ---
s = wide + "xy";
ASSERT_EQ(4, sizeof(s));