Conversation
… a regex class
Three whole-text fast paths in the security scan, all behaviour-preserving.
1. Token-gap scanning seeks the next candidate with a compiled regex instead of
stepping through the text one character at a time in Python. Every token-gap
character lies outside printable ASCII and tab/newline/carriage-return, so the
seek class is a superset and each hit is still confirmed by the exact
predicate. Verified: no code point in Unicode is a gap character that the
seek class fails to match.
2. `_ASCII_CONFUSABLE_PATTERN` is replaced by a frozenset membership test. The
question asked of it is only ever "does this text contain any of these", and
the class holds 1,515 code points spanning 528 disjoint ranges, so as a regex
it costs a bounded scan per character. On 180 KB of ASCII prose:
regex character class 164.30 ms
range-compressed class 44.97 ms
frozenset.isdisjoint 0.70 ms
The class contains no ASCII code point at all, so ordinary text answers with
a single disjointness check. This lifts `_requires_normalized_security_view`
from 166.31 ms to 2.78 ms on that text, a 60x improvement, and the regex
accounted for 164 of those 166 ms.
3. `normalized_security_view` and the letter-spacing span scan are memoized, in
the manner of NVIDIA#570. The view is a frozen dataclass whose `source_offsets`
array is only ever read -- sliced, or copied into a fresh array -- so callers
can share one instance. A caller passing `check_runtime` bypasses the cached
path so runtime budgets are still enforced.
Measured on 901 real skills, two alternating runs per arm on an idle 8-core
host, against upstream main: p95 -8.8%, p99 -12.1%, total scan time -4.1%.
Findings are byte-identical (8,152 on both arms), as are coverage-ledger
outcomes (176) and error counts (0).
The corpus-level gain is much smaller than the microbenchmarks because the
predicate that improves 60x is already memoized by NVIDIA#570, so it runs about 23
times per scan rather than 780. It is still the single most expensive thing left
in that predicate, and the regex class would cost proportionally more on larger
files.
Tests assert the seek class covers every token-gap code point in Unicode, that
gap spans are unchanged across 1,500 randomized texts, that confusable
membership matches the original character class across 1,500 more, that the
memoized spans equal the uncached scan, and that a runtime budget is still
honoured. Full suite: 5808 passed.
Signed-off-by: Steven Moy <github@stevenmoy.com>
yashrajp22
left a comment
There was a problem hiding this comment.
Two reproducible regressions need fixes: sparse token-gap seeking can skip cooperative cancellation checks, and the normalized-view cache retains hundreds of MiB after a modest Unicode input. Verified against main 8028ce5 using exact source snapshots and fresh installed wheels; all 397 selected repository tests passed. Details are inline.
| seek = _TOKEN_GAP_SEEK.search(text, offset) | ||
| if seek is None: | ||
| return | ||
| offset = seek.start() |
There was a problem hiding this comment.
The seek can jump over every offset % 4096 == 0 checkpoint. With ('a\u115fa ' * 8192), a callback that raises on its second call aborts on main but is called only once here. _contextual_default_ignorable_boundary_spans rejects all these in-word gaps, so artifact-integrity's initial next(spans, None) consumes the entire file without another budget check. Could we check runtime after seeking, or track crossed checkpoints, so large inputs still respect cancellation?
There was a problem hiding this comment.
Confirmed and fixed in 7f0f1ab — thank you, this was a real regression and your reproducer landed it exactly.
Reproduced first: with 'aᅟa ' * 8192 and a callback raising on its second call, main aborts and this branch called the callback once.
Rather than a single check after seeking, _check_skipped_checkpoints now fires one check per offset % 4096 checkpoint the seek crossed, so the cadence matches the character-by-character walk it replaced rather than merely approximating it. A test asserts the count equals len // 4096 at 0, 4095, 4096, 10k, 32k and 100k characters.
I also found the whole-string early return (_TOKEN_GAP_CANDIDATE.search(text) is None) had the same gap — it returned without firing any checkpoint on text with no candidates at all. That path is fixed and covered by its own test.
| return next(offsets, None) | ||
|
|
||
|
|
||
| @lru_cache(maxsize=_TEXT_PREDICATE_CACHE_SIZE) |
There was a problem hiding this comment.
Could we bound this cache by stored size or give it a scan-scoped lifetime? With a roughly 5 MB UTF-8 file of numbered U+FDFA runs, the existing windowed static runner leaves 14 normalized views holding about 349 MiB of text/offset arrays after returning; main releases all of them. NFKC expands each U+FDFA to 18 characters, each with a four-byte offset. The 64-entry limit does not bound that expansion, and cleanup_result never clears this cache, so the added memory remains in long-lived scanner processes even after the input is released.
There was a problem hiding this comment.
Confirmed and fixed in 7f0f1ab. You are right that entry count bounds nothing here — I reproduced the shape of it locally (14 views of U+FDFA runs retaining tens of MiB, scaling exactly as you describe).
Two changes:
- The cache is now bounded by stored characters (4M budget) rather than entries, and declines to retain any single view larger than the whole budget. Forty expanding inputs settle at 2.88M stored characters instead of growing without limit.
clear_security_text_caches()is called fromcleanup_result, alongside the existingclear_python_ast_cache— following the precedent already there rather than inventing a new hook. That also releases the text keys the predicate caches from perf(security): memoize the pure text predicates behind security views #570 hold, which is a retention path I had missed independently of this PR.
Tests cover the size bound, the oversized-single-view case, clearing, teardown through cleanup_result, and that a view evicted and rebuilt is identical to the original.
Performance is preserved: p95 −10.3%, p99 −12.7% against main over 901 real skills, findings byte-identical. The mean gain narrows from −4.1% to −2.6%, which is the cost of the restored checkpoints and is the right trade.
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Reviewed exact head 9dc3c201018c4dfe4fa221cc02fb6f7c56c2ccc9 and the complete fast-path/cache diff.
Two current-head regressions already documented inline remain blocking:
- Sparse regex seeking can jump across all 4,096-character cooperative runtime checkpoints, so cancellation may not be observed while a long input is scanned.
- The new 64-entry normalized-view cache is count-bounded but not byte-bounded; modest expanding Unicode inputs can leave hundreds of MiB retained after scanning.
Please preserve the performance improvements while restoring periodic cancellation and giving the derived-view cache a byte bound or scan-scoped lifetime. I verified the control flow and did not duplicate the existing inline comments.
…-view cache Addresses both blocking findings on this PR. Both reproduced first; both were real. Cancellation. Seeking the next candidate jumps over the `offset % 4096` checkpoints a character-by-character walk would hit, so a long scan could not be cancelled. On the reviewer's reproducer -- `'aᅟa ' * 8192` with a callback raising on its second call -- main aborts and this branch called the callback once. `_check_skipped_checkpoints` now runs one check per checkpoint crossed, reproducing the walk's cadence exactly: verified equal to `len // 4096` at 0, 4095, 4096, 10k, 32k and 100k characters. The whole-string early-return path had the same gap and is covered too. Memory. The derived-view cache was bounded by entry count, which bounds nothing when normalization expands its input -- NFKC turns one U+FDFA into 18 characters, each carrying a four-byte offset. It is now bounded by stored characters (4M budget) and declines to retain any single view larger than the whole budget. Forty expanding inputs settle at 2.88M stored characters instead of growing without limit. Retention after a scan. clear_security_text_caches() releases the view cache and the predicate caches, and is called from cleanup_result alongside the existing clear_python_ast_cache. That also releases the text keys the predicate caches hold, so a long-lived scanner process no longer keeps a scanned file's content alive after the scan that produced it. Performance is preserved. Full suite, 901 real skills, two alternating runs per arm against upstream main: p95 -10.3%, p99 -12.7%, total scan time -2.6%. Findings remain byte-identical (8,152 on both arms), as do coverage-ledger outcomes (176) and error counts (0). The mean gain narrows from -4.1% to -2.6%, which is the cost of the restored checkpoints and is the right trade. Thirteen new tests cover the checkpoint cadence at six lengths, cancellation across sparse in-word gaps and on text with no candidates, the size bound, the oversized-single-view case, clearing, teardown via cleanup_result, and that an evicted-then-rebuilt view is identical to the original. Full suite: 5821 passed. Signed-off-by: Steven Moy <github@stevenmoy.com>
|
Both blocking findings are fixed in 7f0f1ab. @yashrajp22 @rng1995 — thank you both, these were real regressions and the reproducers made them quick to confirm. I reproduced each against this branch before changing anything. 1. Cooperative cancellation. Seeking jumps over the
2. Derived-view cache. Entry count bounds nothing when NFKC expands one U+FDFA into 18 characters each carrying a four-byte offset. The cache is now bounded by stored characters (4M budget) and refuses any single view larger than the budget; 40 expanding inputs settle at 2.88M stored characters instead of growing without limit. 3. Retention after a scan. Performance is preserved, measured over 901 real skills with two alternating runs per arm against main: p95 −10.3%, p99 −12.7%, total scan time −2.6%. Findings byte-identical (8,152 both arms), coverage-ledger outcomes identical (176), zero errors. The mean gain narrows from −4.1% to −2.6% — that is the cost of the restored checkpoints, and it is the right trade. 13 new tests: checkpoint cadence at six lengths, cancellation across sparse in-word gaps and on candidate-free text, the size bound, the oversized-single-view case, clearing, teardown via I kept 9dc3c20 intact and stacked the fix on top rather than force-pushing a rebase, so your inline threads stay anchored to the lines you reviewed. |
Follow-up to #569 and #570, against current
main. Three whole-text fast paths, all behaviour-preserving.Summary of Changes
1. Seek token gaps in C.
_token_bridging_gap_spansstepped through the text one character at a time in Python looking for the next gap character. It now seeks with a compiled regex. Every token-gap character lies outside printable ASCII and tab/newline/carriage-return, so the seek class is a superset and each hit is still confirmed by the exact predicate.2. Confusable containment as a set, not a regex class.
_ASCII_CONFUSABLE_PATTERNwas a character class built from 1,515 individually escaped code points spanning 528 disjoint ranges. The only question ever asked of it is "does this text contain any of these", which a frozenset answers in one C-level pass. On 180 KB of ASCII prose:frozenset.isdisjointThe class contains no ASCII code point at all, so ordinary text answers with a single disjointness check.
3. Memoize two builders.
normalized_security_viewand the letter-spacing span scan, in the manner of #570. The view is a frozen dataclass whosesource_offsetsarray is only ever read — sliced, or copied into a fresh array — so callers can share one instance. A caller passingcheck_runtimebypasses the cached path, so runtime budgets are still enforced.Root Cause Analysis
Profiling an LLM-enabled scan on
mainafter #569/#570 left these as the remaining per-character primitives on a single 237 KB skill:str.isascii12.5M calls,str.isalpha5.4M,str.translate4.8M. Item 1 removes the first of those — it was the fast-path dispatch #569 introduced, which made the per-character check cheap without removing the per-character step.Item 2 was a surprise.
_requires_normalized_security_viewcost 166.31 ms on 180 KB of ASCII, and 164.30 ms of that was the confusable regex alone — the other four checks in that function total under 2 ms. A character class with hundreds of disjoint ranges costs a bounded scan at every position; set membership is a hash lookup over the text's distinct characters. The predicate now runs in 2.78 ms, a 60× improvement.Worth noting what this replaced: I had expected to gate the normalization path behind a pure-ASCII prefilter, on the theory that NFKC normalization and homoglyph skeletons cannot fire on ASCII. That turned out to be unnecessary — the normalization checks were never the cost. One pathological regex was.
Test Coverage & Verification
New
tests/nodes/analyzers/test_security_scan_fast_paths.py:check_runtimestill has it invoked.normalized_security_viewreturns stable text and offsets across repeated calls.Full suite:
5808 passed, 14 skipped, 39 deselected, 4 xfailed.ruff check,ruff format --checkandmypyclean.End-to-end on real skills. 901 real skills, two alternating runs per arm on an idle 8-core host, against current
main:mainFindings are byte-identical (8,152 on both arms), as are coverage-ledger outcomes (176) and error counts (0).
Scope
The corpus-level gain is much smaller than the microbenchmarks, and I would rather say so than lead with the 60×. The predicate that improves 60× is already memoized by #570, so it runs about 23 times per scan rather than 780. It remains the single most expensive thing left inside that predicate, and the regex class would cost proportionally more on larger files than this corpus contains.
The two largest remaining costs are untouched:
_letter_spacing_run_spansand, insecurity_reconstruction.py,_encoded_tag_directivesand_quoted_directives. Those look like genuine algorithmic work rather than incidental overhead, and I did not want to restructure detection logic without a maintainer's view. Happy to take a look if that would be useful.