Skip to content

Commit 1d01975

Browse files
olwangclaude
andcommitted
perf(reg-vm): elision Slice 3 — widen read-only-safe set to proven-pure readers (borrow-by-default)
Promote all proven-pure String.* and Bytes.* intrinsic readers from the conservative DeepCopy-elision keep-default to PureFreshReader, so a read-only `read String`/`read Bytes` param no longer forces its prologue DeepCopy to be kept. Each promoted op was audited against intrinsics/{string,bytes}.rs: borrows its receiver by & (never borrow_mut), never stores an arg into streams/channels/resource state, and returns a fresh scalar/Rc<String>/Rc<Vec>/ List that shares no Rc with an arg. MatchMapGet/MatchSortedMapGet rejected (they alias-return an element and would need a taint-propagation edge this slice does not touch). Readability refactor makes deepcopy_intrinsic_class / deepcopy_instr_forces_keep an explicit three-way split (escape / read-only-safe / unclassified->keep); the fail-safe catch-all default and the exhaustive deepcopy_collect_regs are unchanged. Adds a positive elision guard (deepcopy_elision_fires_for_string_read_param) and a negative over-promotion guard (deepcopy_elision_kept_for_stored_read_param). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dad62a0 commit 1d01975

3 files changed

Lines changed: 262 additions & 6 deletions

File tree

crates/rsscript/src/reg_vm/model.rs

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2252,8 +2252,15 @@ enum IntrinsicTaintClass {
22522252
/// `deepcopy_instr_forces_keep`/`deepcopy_elidable_param_regs`.
22532253
fn deepcopy_intrinsic_class(intrinsic: RegIntrinsic) -> IntrinsicTaintClass {
22542254
use IntrinsicTaintClass::{AliasReturner, PureFreshReader};
2255+
// Three-way split, all POSITIVE (an intrinsic is trusted only when named):
2256+
// 1. PureFreshReader → READ-ONLY-SAFE, result is fresh (no arg aliasing).
2257+
// 2. AliasReturner → READ-ONLY-SAFE args, but result aliases an arg's `Rc`
2258+
// (taint propagates arg→dst in `deepcopy_elidable_param_regs`).
2259+
// 3. everything else → UNCLASSIFIED ⇒ `Keep` (the fail-safe default arm; soundness
2260+
// rests on it — Tier-3 IO/channels/streams/tensor/json/… and any
2261+
// not-yet-audited variant conservatively pins the copy).
22552262
match intrinsic {
2256-
// ---- PureFreshReader: fresh scalar/bool/collection-of-scalars, no arg aliasing. ----
2263+
// ---- (1) PureFreshReader: fresh scalar/bool/collection-of-scalars, no arg aliasing. ----
22572264
// List
22582265
RegIntrinsic::ListIsEmpty
22592266
| RegIntrinsic::ListContains
@@ -2311,7 +2318,71 @@ fn deepcopy_intrinsic_class(intrinsic: RegIntrinsic) -> IntrinsicTaintClass {
23112318
| RegIntrinsic::CharIsLower
23122319
| RegIntrinsic::CharIsUpper
23132320
| RegIntrinsic::CharIsWhitespace
2314-
| RegIntrinsic::CharCompare => PureFreshReader,
2321+
| RegIntrinsic::CharCompare
2322+
// String — pure readers (Slice 3). Each takes its receiver by `&`
2323+
// (`expect_string_ref`, never `borrow_mut`), never stores an arg into
2324+
// `self.streams`/`self.channels`/resource state, and RETURNS a FRESH value:
2325+
// a scalar (`Int`/`Bool`/`Char`), a brand-new `Rc<String>` (every
2326+
// `VmValue::string(..)` is `Rc::new(into())`, so even `String.copy`/`slice`/
2327+
// `trim`/`replace` allocate — the result NEVER aliases the arg's `Rc`), a fresh
2328+
// `List` (`chars`/`lines`/`split`), or an `Option`/`Result` wrapping one of those.
2329+
// Verified against `intrinsics/string.rs`: none mutate/store/alias an arg. This
2330+
// lets the elision pass drop a redundant `read String` prologue DeepCopy whose
2331+
// only keep-forcing use is one of these read-only string ops (borrow-by-default).
2332+
| RegIntrinsic::StringAfter
2333+
| RegIntrinsic::StringBefore
2334+
| RegIntrinsic::StringBuilderNew
2335+
| RegIntrinsic::StringCharAt
2336+
| RegIntrinsic::StringChars
2337+
| RegIntrinsic::StringContains
2338+
| RegIntrinsic::StringCount
2339+
| RegIntrinsic::StringCopy
2340+
| RegIntrinsic::StringEndsWith
2341+
| RegIntrinsic::StringFormat
2342+
| RegIntrinsic::StringFromBool
2343+
| RegIntrinsic::StringFromFloat
2344+
| RegIntrinsic::StringFromInt
2345+
| RegIntrinsic::StringIndexOf
2346+
| RegIntrinsic::StringIsEmpty
2347+
| RegIntrinsic::StringJoin
2348+
| RegIntrinsic::StringLines
2349+
| RegIntrinsic::StringLen
2350+
| RegIntrinsic::StringPadLeft
2351+
| RegIntrinsic::StringPadRight
2352+
| RegIntrinsic::StringParseFloat
2353+
| RegIntrinsic::StringParseInt
2354+
| RegIntrinsic::StringRepeat
2355+
| RegIntrinsic::StringReplace
2356+
| RegIntrinsic::StringReplaceFirst
2357+
| RegIntrinsic::StringReverse
2358+
| RegIntrinsic::StringSlice
2359+
| RegIntrinsic::StringSplit
2360+
| RegIntrinsic::StringStartsWith
2361+
| RegIntrinsic::StringStripPrefix
2362+
| RegIntrinsic::StringToLowercase
2363+
| RegIntrinsic::StringToUppercase
2364+
| RegIntrinsic::StringTrim
2365+
| RegIntrinsic::StringTrimEnd
2366+
| RegIntrinsic::StringTrimStart
2367+
// Bytes — pure readers (Slice 3). Each takes its receiver by `&`
2368+
// (`expect_bytes_ref`, never `borrow_mut`), never stores an arg, and returns a
2369+
// FRESH value: a scalar (`BytesLen`→`Int`, `BytesIsEmpty`/`BytesViewStartsWith`→
2370+
// `Bool`), a freshly-allocated `Rc<Vec<u8>>` (`concat`/`slice`/`from_string`/
2371+
// `from_uints`/`view_to_bytes` all `Rc::new` a new `Vec`), a fresh `Rc<String>`
2372+
// (`to_string`), or a fresh `List` (`to_uints`). Verified against
2373+
// `intrinsics/bytes.rs`: none mutate/store/alias an arg (`BytesConsume` merely
2374+
// reads and returns `Unit`).
2375+
| RegIntrinsic::BytesConcat
2376+
| RegIntrinsic::BytesConsume
2377+
| RegIntrinsic::BytesFromString
2378+
| RegIntrinsic::BytesFromUints
2379+
| RegIntrinsic::BytesIsEmpty
2380+
| RegIntrinsic::BytesLen
2381+
| RegIntrinsic::BytesSlice
2382+
| RegIntrinsic::BytesToString
2383+
| RegIntrinsic::BytesToUints
2384+
| RegIntrinsic::BytesViewStartsWith
2385+
| RegIntrinsic::BytesViewToBytes => PureFreshReader,
23152386

23162387
// ---- AliasReturner: result shares an arg's inner `Rc`; propagate taint arg→dst. ----
23172388
// List
@@ -2348,7 +2419,7 @@ fn deepcopy_intrinsic_class(intrinsic: RegIntrinsic) -> IntrinsicTaintClass {
23482419
// Deque
23492420
| RegIntrinsic::DequeToList => AliasReturner,
23502421

2351-
// Everything else (Tier-3 and any unclassified variant): conservative keep.
2422+
// ---- (3) UNCLASSIFIED → KEEP (fail-safe default; Tier-3 + any un-audited variant). ----
23522423
_ => IntrinsicTaintClass::Keep,
23532424
}
23542425
}
@@ -2379,12 +2450,16 @@ fn deepcopy_intrinsic_class(intrinsic: RegIntrinsic) -> IntrinsicTaintClass {
23792450
/// explicitly classified read-only-safe above.
23802451
fn deepcopy_instr_forces_keep(instr: &RegInstr, tainted: &[bool], n_regs: usize) -> bool {
23812452
let is_t = |r: Reg| r < n_regs && tainted[r];
2382-
// In-place mutation of a tainted heap receiver — the direct leak.
2453+
// ---- POSITIVE ESCAPE (keep): in-place mutation of a tainted heap receiver — the direct
2454+
// leak. This is the one escape we detect structurally rather than via the default arm. ----
23832455
if let Some(recv) = deepcopy_heap_mutation_receiver(instr) {
23842456
if is_t(recv) {
23852457
return true;
23862458
}
23872459
}
2460+
// ---- POSITIVE READ-ONLY-SAFE (elide): the arms below return `false` (or key only off a
2461+
// tainted `mut`-arg / classified-`Keep` intrinsic). Anything not matched here falls through
2462+
// to the UNCLASSIFIED → KEEP fail-safe default at the bottom. ----
23882463
match instr {
23892464
// Alias propagation: `dst` aliases `src`'s inner `Rc` (already tainted by the closure);
23902465
// the op only READS `src`, so it never leaks by itself.
@@ -2464,8 +2539,11 @@ fn deepcopy_instr_forces_keep(instr: &RegInstr, tainted: &[bool], n_regs: usize)
24642539
| RegInstr::LoadNone { .. }
24652540
| RegInstr::RuntimeError { .. } => false,
24662541

2467-
// Everything else (stores, returns, captures, spawns, `Manage`, intrinsics, and every
2468-
// unlisted mutator): conservatively keep the copy if a tainted register is involved.
2542+
// ---- UNCLASSIFIED → KEEP (fail-safe default; SOUNDNESS BACKBONE — DO NOT WEAKEN). ----
2543+
// Everything else (stores, returns, captures, spawns, `Manage`, `Match*MapGet` extractions,
2544+
// and every unlisted mutator): conservatively keep the copy if a tainted register is
2545+
// involved. `deepcopy_collect_regs` is exhaustive (no wildcard) so a new `RegInstr` variant
2546+
// lands here by default rather than silently escaping the analysis.
24692547
other => {
24702548
let mut regs = Vec::new();
24712549
deepcopy_collect_regs(other, &mut regs);

crates/rsscript/src/reg_vm/tests.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,143 @@ fn main() -> Unit {
825825
assert_eq!(output.stdout, "42\n");
826826
}
827827

828+
/// Slice 3 (borrow-by-default): a `read String` param whose only use is a
829+
/// proven-pure reader (`String.len`, now classified `PureFreshReader`) must have
830+
/// its prologue `DeepCopy` ELIDED. Before Slice 3 every `String.*` intrinsic hit
831+
/// the conservative `Keep` arm, so `String.len(read s)` pinned the copy and each
832+
/// call deep-copied the whole string. `String.len` returns a fresh `Int` and never
833+
/// mutates/stores/aliases its arg, so sharing the caller's `Rc` is sound.
834+
#[test]
835+
fn deepcopy_elision_fires_for_string_read_param() {
836+
let source = r#"
837+
fn measure(s: read String) -> Int {
838+
return String.len(value: read s)
839+
}
840+
841+
fn main() -> Unit {
842+
Log.write(message: read String.from_int(value: measure(s: read "hello")))
843+
return Unit
844+
}
845+
"#;
846+
let executable = reg_vm_compile_source("deepcopy-elision-string.rss", source)
847+
.expect("lowering succeeds");
848+
849+
let measure_id = executable.unit.function_ids["measure"];
850+
let measure = executable.unit.functions[measure_id].as_ref();
851+
let elided = measure
852+
.code
853+
.iter()
854+
.filter(|instr| matches!(instr, RegInstr::DeepCopyElided { .. }))
855+
.count();
856+
let eager = measure
857+
.code
858+
.iter()
859+
.filter(|instr| matches!(instr, RegInstr::DeepCopy { .. }))
860+
.count();
861+
if crate::reg_vm::model::elide_deepcopy_enabled_for_test() {
862+
assert!(
863+
elided >= 1 && eager == 0,
864+
"elision ON: `read String` param whose only use is the pure reader \
865+
`String.len` must be elided, got {elided} elided / {eager} eager: {:#?}",
866+
measure.code,
867+
);
868+
} else {
869+
assert!(
870+
eager >= 1 && elided == 0,
871+
"elision OFF: eager DeepCopy must be retained: got {elided} elided / \
872+
{eager} eager: {:#?}",
873+
measure.code,
874+
);
875+
}
876+
877+
// Elision must not change behavior: len("hello") == 5.
878+
let output = executable
879+
.eval_main_with_args(Vec::<String>::new())
880+
.expect("program should still run");
881+
assert_eq!(output.stdout, "5\n");
882+
}
883+
884+
/// Slice 3 NEGATIVE guard (over-promotion): a `read List<Int>` param that IS STORED
885+
/// into a struct (then reloaded and mutated in a loop) must KEEP its prologue
886+
/// `DeepCopy`, even though a promoted pure reader (`String.len` on a fresh string
887+
/// derived from it) is also called. Storing lowers to `MakeStruct`, an UNCLASSIFIED
888+
/// instruction that references the tainted param register and so hits the fail-safe
889+
/// `Keep` default — the param stays tainted and the copy is retained. This proves
890+
/// Slice 3 widened only the READ-ONLY-SAFE set, not the ESCAPE set: a storing op is
891+
/// still caught, and behavior is byte-for-byte unchanged (caller's `xs[0]` stays 7,
892+
/// the callee mutated only its own deep copy). Mirrors the shape of the native
893+
/// `native_store_reload_mutate_non_mut_heap_param_does_not_leak` leak guard.
894+
#[test]
895+
fn deepcopy_elision_kept_for_stored_read_param() {
896+
let source = r#"
897+
features: local
898+
899+
struct Box {
900+
items: List<Int>
901+
}
902+
903+
fn stash(xs: read List<Int>, n: Int) -> Int {
904+
let probe = String.len(value: read String.from_int(value: List.get<Int>(list: read xs, index: 0)))
905+
let b = Box(items: read xs)
906+
let mut i = 0
907+
while i < n {
908+
let mut inner = b.items
909+
List.set<Int>(list: mut inner, index: 0, value: read i)
910+
i = i + 1
911+
}
912+
return List.get<Int>(list: read xs, index: 0) + probe
913+
}
914+
915+
fn main() -> Unit {
916+
local xs = List.new<Int>()
917+
List.push<Int>(list: mut xs, value: read 7)
918+
let r = stash(xs: read xs, n: read 3)
919+
Log.write(message: read String.from_int(value: List.get<Int>(list: read xs, index: 0)))
920+
Log.write(message: read String.from_int(value: r))
921+
return Unit
922+
}
923+
"#;
924+
let executable =
925+
reg_vm_compile_source("deepcopy-elision-store.rss", source).expect("lowering succeeds");
926+
927+
let stash_id = executable.unit.function_ids["stash"];
928+
let stash = executable.unit.functions[stash_id].as_ref();
929+
let elided = stash
930+
.code
931+
.iter()
932+
.filter(|instr| matches!(instr, RegInstr::DeepCopyElided { .. }))
933+
.count();
934+
let eager = stash
935+
.code
936+
.iter()
937+
.filter(|instr| matches!(instr, RegInstr::DeepCopy { .. }))
938+
.count();
939+
// The store forces the copy to be kept regardless of the elision gate: with the
940+
// gate ON the DeepCopy must NOT be neutralized (elided == 0); with it OFF the eager
941+
// DeepCopy is likewise retained. Either way, no elision for the stored param.
942+
assert_eq!(
943+
elided, 0,
944+
"over-promotion guard: a `read Map` param stored into a struct must KEEP its \
945+
copy (no DeepCopyElided), got {elided} elided / {eager} eager: {:#?}",
946+
stash.code,
947+
);
948+
assert!(
949+
eager >= 1,
950+
"over-promotion guard: the eager DeepCopy of the stored `read Map` param must \
951+
be retained, got {elided} elided / {eager} eager: {:#?}",
952+
stash.code,
953+
);
954+
955+
// Soundness check — elision must not leak to the caller: the caller's xs[0] stays 7
956+
// (line 1), proving the prologue copy was KEPT. The callee's own xs is a separate deep
957+
// copy; via the stored `b.items` alias the loop drives it to 2 (last i in 0..3), so
958+
// `xs[0] + probe == 2 + len("7") == 3` (line 2). Deterministic either way.
959+
let output = executable
960+
.eval_main_with_args(Vec::<String>::new())
961+
.expect("program should still run");
962+
assert_eq!(output.stdout, "7\n3\n");
963+
}
964+
828965
#[test]
829966
fn jit_runs_scalar_self_recursion_on_flat_executor() {
830967
let source = r#"

docs/ledgers/rss-selfhost-ledger.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,47 @@ gap is VM value-representation / intrinsic-dispatch cost (the next big lever).
707707
mismatches; lexer perf holds (~46× ratio, rss/VM time ~700 ms stable — the ratio's
708708
jitter is the tiny Rust denominator, not the VM). New guard:
709709
`reg_vm::tests::…::deepcopy_elision_fires_for_option_scalar_pattern_bind`.
710+
- **Slice 3 of borrow-by-default (2026-07-01):** widened the READ-ONLY-SAFE
711+
intrinsic set — a SOUND whitelist widening, NOT a default flip. Slices 1–2
712+
stopped scalar EXTRACTIONS from tainting; Slice 3 stops proven-pure READERS of a
713+
`read String`/`Bytes` param from pinning the copy. Previously every `String.*`
714+
and `Bytes.*` intrinsic fell through to the conservative `Keep` arm of
715+
`deepcopy_intrinsic_class`, so `String.len(read s)` (or any read-only string/bytes
716+
op) forced the prologue `DeepCopy` to be kept — a `read String`/`read Bytes` param
717+
used only in read-only ways was still deep-copied per call. **Audit + promotion:**
718+
every intrinsic in `intrinsics/string.rs` and `intrinsics/bytes.rs` was verified to
719+
(a) borrow its receiver by `&` (`expect_string_ref`/`expect_bytes_ref`, never
720+
`borrow_mut`), (b) never store an arg into `self.streams`/`self.channels`/resource
721+
state, and (c) return a FRESH value — a scalar, a brand-new `Rc<String>`
722+
(`VmValue::string` is always `Rc::new(into())`, so even `copy`/`slice`/`trim`/
723+
`replace` allocate and NEVER alias the arg's `Rc`), a freshly-`Rc::new`'d
724+
`Vec<u8>`, or a fresh `List`. All were promoted to `PureFreshReader`: the 35
725+
`String.*` readers (`StringAfter/Before/BuilderNew/CharAt/Chars/Contains/Count/
726+
Copy/EndsWith/Format/FromBool/FromFloat/FromInt/IndexOf/IsEmpty/Join/Lines/Len/
727+
PadLeft/PadRight/ParseFloat/ParseInt/Repeat/Replace/ReplaceFirst/Reverse/Slice/
728+
Split/StartsWith/StripPrefix/ToLowercase/ToUppercase/Trim/TrimEnd/TrimStart`) and
729+
the 11 `Bytes.*` readers (`BytesConcat/Consume/FromString/FromUints/IsEmpty/Len/
730+
Slice/ToString/ToUints/ViewStartsWith/ViewToBytes`). **Rejected (left in the
731+
keep-default, conservatism over completeness):** `MatchMapGet`/`MatchSortedMapGet`
732+
— these are alias-RETURNING extractions (`map.borrow().get(&key).cloned()` shares
733+
the element's `Rc` into `value_dst`), so promoting them read-only-safe would need a
734+
`map→value_dst` edge in the taint-propagation closure, which this slice does not
735+
touch; without it a later mutation of the extracted heap value would leak, so they
736+
stay in the fail-safe default. The model is now **"keep only on PROVEN escape
737+
(store / mutate-through-alias / retain / return / unclassified)"**; borrow-by-default
738+
now covers read-only `String`/`Bytes` params (and, via Slices 1–2, `Map`/`List`/
739+
scalar reads). Also a readability refactor (no behavior change):
740+
`deepcopy_intrinsic_class` / `deepcopy_instr_forces_keep` now read as an explicit
741+
three-way split — POSITIVE ESCAPE (keep) / POSITIVE READ-ONLY-SAFE (elide) /
742+
UNCLASSIFIED → KEEP (the fail-safe default arm, UNCHANGED — soundness backbone;
743+
`deepcopy_collect_regs` stays exhaustive/no-wildcard). Soundness: catch-all default
744+
unchanged; the new negative guard proves a stored `read` param still keeps its copy
745+
(no over-promotion). Full suite (628 lib + 456 runtime incl. all three
746+
`does_not_leak` guards + 35 differential + 628 static) green; parity 556/556 × 3, 0
747+
mismatches; lexer perf holds (rss/VM ~53× ratio under host load, no regression —
748+
the change only ADDS elisions). New guards:
749+
`reg_vm::tests::…::deepcopy_elision_fires_for_string_read_param` (positive) and
750+
`…::deepcopy_elision_kept_for_stored_read_param` (negative / over-promotion).
710751

711752
### SH-023 — self-hosted checker reaches RS0005 parity at declaration level; the merged callable namespace is the load-bearing rule
712753

0 commit comments

Comments
 (0)