compiler: fix the quadratic — strlen() on every character access #132
Reference in New Issue
Block a user
Delete Branch "fix/compiler-quadratic-strlen"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The bug.
str_char_code()andstr_slice()each calledstrlen()on every invocation. The lexer walks source one character at a time, so every character access rescanned the whole remaining input — O(n) per character over n characters = O(n²).How it was found. By sampling the running process, not reading code — the same method that resolved tonight's engram outage after four wrong theories. A geometric sweep showed wall-clock rising 3.0×, 3.0×, 4.0×, 4.14× per doubling (converging on 4× = quadratic), and a stack sample put 779 of 779 samples inside
lex(), every one bottoming out in_platform_strlen.The fix, and its subtlety. Cache the length — but El strings are arena-allocated, so a freed pointer can be reused for a different string at the same address. A naive pointer-keyed cache would return a stale length and read past the end of the new string, trading a performance bug for a memory-safety one. So entries carry a generation; a hit requires pointer and generation to match; every path that frees or mutates a runtime string bumps it (
el_arena_pop,seed_request_end,__str_set_char).Measured:
The speedup grows with input size — the signature of removing a complexity class, not a constant factor. After the fix each doubling adds ~0.01s: linear.
Correctness, verified not assumed: byte-identical output on every sweep input, on the 422 KB concatenation, on
string_test.el, and the self-hosting fixpoint holds byte-identical. Plus a newtests/runtime/str_cache_test.el— 17 assertions covering bounds, empty strings, negative indices, slice clamping, distinct strings not sharing a cached length, 1,000 interleaved strings forcing cache-slot collisions, and a grown string not reporting its old length. All pass.This is the defect that made
dist/soul.ca committed artifact — elc couldn't run in CI because it needed 24 GB+ and minutes. It needs neither now.THE BUG. str_char_code() and str_slice() each called strlen() on every invocation. The lexer walks source one character at a time, so every character access rescanned the whole remaining input: O(n) per character over n characters = O(n^2). el_val_t str_char_code(el_val_t s, el_val_t i) { ... int64_t n = (int64_t)strlen(str); // <- O(n), every call if (idx < 0 || idx >= n) return 0; return str[idx]; } HOW IT WAS FOUND. Not by reading code — by sampling the running process, which is the same method that resolved tonight's engram outage after four wrong theories. A geometric sweep of synthetic sources showed wall-clock rising 3.0x, 3.0x, 4.0x, 4.14x per doubling (converging on 4x = quadratic), and a stack sample put 779 of 779 samples inside lex(), every one bottoming out in _platform_strlen via str_char_code and str_slice. THE FIX. Remember the length instead of recomputing it. The subtlety is INVALIDATION: El strings are arena-allocated, so a freed pointer can be reused for a different string at the same address, and a naive pointer-keyed cache would hand back a stale length and read past the end of the new string — trading a performance bug for a memory-safety one. So entries carry a generation, a hit requires pointer AND generation to match, and every path that frees or mutates a runtime string bumps the generation: el_arena_pop, seed_request_end, __str_set_char. Stale entries cannot be believed; they miss and recompute. MEASURED, same host, same inputs: n(fns) before after 512 0.10s 0.01s 1024 0.37s 0.02s 2048 1.51s 0.03s 50x the compiler's own 422 KB source concatenated (DESIGN.md's 3.58s case): 3.55s -> 0.03s 118x The speedup GROWS with input size, which is the signature of removing a complexity class rather than a constant factor. After the fix each doubling adds ~0.01s: linear. CORRECTNESS, verified rather than assumed: - byte-identical output on every sweep input (n = 128..2048) - byte-identical output on the 422 KB compiler concatenation - byte-identical output on tests/runtime/string_test.el - self-hosting fixpoint byte-identical - new tests/runtime/str_cache_test.el: 17 assertions covering bounds, empty strings, negative indices, slice clamping, distinct strings not sharing a cached length, 1000 interleaved strings forcing cache-slot collisions, and a grown string not reporting its old length. All pass. This is the defect that made dist/soul.c a committed artifact: elc could not run in CI because it needed 24 GB+ and minutes. It needs neither now.