0%

Section 10: Thread-Local Non-Atomic ARC

Context: Currently, ori_rt uses AtomicI64 with Relaxed/Release/Acquire ordering for all RC operations. This is correct for thread-shared values but wasteful for thread-local ones. Most values in most programs never cross thread boundaries — they’re created, used, and freed within a single thread.

Rust solved this by having two types: Rc (non-atomic, thread-local) and Arc (atomic, thread-safe). Ori doesn’t expose this distinction to the programmer — the compiler decides automatically.

Reference implementations:

  • Rust library/alloc/src/rc.rs vs library/alloc/src/sync.rs: Rc uses Cell<usize> (non-atomic), Arc uses AtomicUsize. Programmer chooses.
  • Swift: All RC is atomic by default, but isKnownUniquelyReferenced() enables COW without RC overhead. No automatic non-atomic promotion.
  • CPython: GIL-protected — all RC is effectively non-atomic because only one thread runs at a time.

Architecture boundary: AIMS combines its Locality dimension with call-graph/thread-boundary evidence and freezes ThreadReachability::{Confined, PotentiallyShared} by stable allocation identity. This is a safety requirement, not an opcode selection.

VmLayoutPlan and CompiledLayoutPlan independently choose a synchronization mechanism and prove it satisfies that exact fact. A conservative atomic choice may satisfy Confined; a plain-load/store choice cannot satisfy PotentiallyShared.

No physical planner may re-run thread escape analysis.

Depends on: §08 (AIMS locality/lifetime facts), §09 (neutral OwnerBound and OwnershipObservationFacts plus independently validated physical metadata).


10.1 AIMS Thread-Escape Evidence

File(s): compiler/ori_arc/src/aims/ interprocedural/effect analysis and the executable artifact’s typed ThreadReachabilityFacts

Extend the AIMS analysis from §08 to track thread-boundary reachability. The output is an exact stable-ID fact map, not RcAtomicity, a representation-owned locality enum, or a runtime-helper choice.

  • Add ThreadReachability::{Confined, PotentiallyShared} as the published fact carrier; incomplete or unknown evidence defaults conservatively to PotentiallyShared.

  • Identify thread boundary operations:

    • spawn() — values captured by the spawned closure cross threads
    • chan.send(value) — value crosses thread via channel
    • Global mutable state (if Ori adds it) — shared by all threads
    • FFI calls with unknown thread behavior → conservative (ThreadShared)
  • Propagate thread-locality:

    pub fn derive_thread_reachability(
        func: &ArcFunction,
        locality: &AllocationLocalityFacts,
        thread_edges: &ThreadEscapeEvidence,
        pool: &Pool,
    ) -> FxHashMap<AllocationSiteId, ThreadReachability> {
        let mut reachability = FxHashMap::default();
    
        for alloc in func.allocations() {
            // Thread-boundary and unknown-call evidence takes precedence over
            // lexical lifetime: a function-local allocation captured by spawn is
            // still potentially shared.
            let fact = if thread_edges.may_cross_or_unknown(alloc) {
                ThreadReachability::PotentiallyShared
            } else if locality.is_block_or_function_local(alloc)
                || thread_edges.closed_world_proves_confined(alloc)
            {
                ThreadReachability::Confined
            } else {
                ThreadReachability::PotentiallyShared
            };
            reachability.insert(alloc, fact);
        }
    
        reachability
    }
  • Whole-program optimization:

    • Apply RL-21 only when the closed program has no spawn, channel operation, or FFI export of Ori-managed pointers
    • derive the proof from the shared callable/effect graph, not a backend scan
    • when proven, AIMS freezes ThreadReachability::Confined for every applicable allocation; each physical plan may then select its cheapest sufficient mechanism
  • /tpr-review passed — independent review found no critical or major issues (or all findings triaged)

  • /impl-hygiene-review passed — hygiene review clean. MUST run AFTER /tpr-review is clean.

  • Subsection close-out (10.1) — MANDATORY before starting the next subsection. Run /improve-tooling retrospectively on THIS subsection’s debugging journey (per .claude/skills/improve-tooling/SKILL.md “Per-Subsection Workflow”): which diagnostics/ scripts you ran, where you added dbg!/tracing calls, where output was hard to interpret, where test failures gave unhelpful messages, where you ran the same command sequence repeatedly. Forward-look: what tool/log/diagnostic would shorten the next regression in this code path by 10 minutes? Implement improvements NOW (zero deferral) and commit each via SEPARATE /commit-push using a valid conventional-commit type (build(diagnostics): ... — surfaced by section-10.1 retrospectivebuild/test/chore/ci/docs are valid; tools(...) is rejected by the lefthook commit-msg hook). Mandatory even when nothing felt painful. If genuinely no gaps, document briefly: “Retrospective 10.1: no tooling gaps”. Update this subsection’s status in section frontmatter to complete.

  • /sync-claude section-close doc sync — verify Claude artifacts across all section commits. Map changed crates to rules files, check CLAUDE.md, canon.md. Fix drift NOW.

  • Repo hygiene check — run diagnostics/repo-hygiene.sh --check and clean any detected temp files.


10.2 Non-Atomic RC Runtime

File(s): compiler/ori_rt/src/rc/nonatomic.rs (new file inside rc/ module)

Module placement: Must live inside rc/ (e.g., rc/nonatomic.rs with mod nonatomic; in rc/mod.rs) to access call_drop_fn and rc_underflow_abort which are pub(super). Note: ori_rt allows unsafe (it is NOT in the #![deny(unsafe_code)] list). Every unsafe block MUST have a // SAFETY: comment.

Risk warning: Non-atomic RC on a value that is actually thread-shared causes data-race UB. Soundness depends on the AIMS locality/thread-escape proof and on validation that each physical synchronization plan satisfies the frozen ThreadReachability; representation or backend reclassification is forbidden.

Memcheck cannot prove that invariant, so the section also requires structural carrier checks, cross-executor event parity, debug guards, and helgrind/TSAN evidence.

  • Add a debug-only RC mode guard before exposing any non-atomic runtime entry point:

    • Store an RcMode flag (Atomic vs NonAtomic) in debug builds only, either in a side table or a debug-only header word
    • ori_rc_inc / ori_rc_dec assert they are not touching an allocation marked non-atomic
    • ori_rc_inc_nonatomic / ori_rc_dec_nonatomic assert they are not touching an allocation marked atomic
    • Release builds pay zero cost for this guard
    • This guard is mandatory; helgrind is a secondary verifier, not the only safety net
  • Implement non-atomic RC operations:

    #[no_mangle]
    pub unsafe extern "C" fn ori_rc_inc_nonatomic(data_ptr: *mut u8) {
        if data_ptr.is_null() { return; }
        // SAFETY: data_ptr was returned by ori_rc_alloc; strong_count is at data_ptr - 8.
        let rc_ptr = data_ptr.sub(8).cast::<i64>();
        let count = *rc_ptr;  // plain load (no atomic)
        if count >= MAX_REFCOUNT {
            std::process::abort();
        }
        *rc_ptr = count + 1;  // plain store (no atomic)
    }
    
    #[no_mangle]
    pub unsafe extern "C" fn ori_rc_dec_nonatomic(
        data_ptr: *mut u8,
        drop_fn: Option<extern "C" fn(*mut u8)>,
    ) {
        if data_ptr.is_null() { return; }
        // SAFETY: data_ptr was returned by ori_rc_alloc; strong_count is at data_ptr - 8.
        let rc_ptr = data_ptr.sub(8).cast::<i64>();
        let count = *rc_ptr;  // plain load (no atomic)
        // Underflow protection — matches ori_rc_dec (rc/mod.rs).
        // Always-on, not debug-only. Catches double-free bugs.
        if count <= 0 {
            rc_underflow_abort(data_ptr);
        }
        *rc_ptr = count - 1;  // plain store (no atomic)
        if count == 1 {
            // Last reference — drop via abort-on-panic guard.
            // ori_rc_dec_nonatomic is nounwind; unwinding through it is UB.
            if let Some(f) = drop_fn {
                call_drop_fn(f, data_ptr);
            }
        }
    }
  • Also provide width-specific non-atomic variants:

    • ori_rc_inc_nonatomic_i8, ori_rc_dec_nonatomic_i8
    • ori_rc_inc_nonatomic_i16, ori_rc_dec_nonatomic_i16
    • Combines with §09 header compression
  • Every physical projection selects and validates its own runtime mechanism from the frozen AIMS fact; layout owns the actual header, width, storage, and synchronization sequence:

    match (facts.thread_reachability(site), layout.rc_sync(site)) {
        (ThreadReachability::PotentiallyShared, RcSync::Atomic(ordering)) =>
            emit_atomic_rc(layout.rc_width(site), ordering),
        (ThreadReachability::Confined, RcSync::Atomic(ordering)) =>
            emit_atomic_rc(layout.rc_width(site), ordering),
        (ThreadReachability::Confined, RcSync::ThreadConfined) =>
            emit_nonatomic_rc(layout.rc_width(site)),
        (ThreadReachability::PotentiallyShared, RcSync::ThreadConfined) =>
            return Err(LayoutError::InsufficientThreadSafety { site }),
    }
  • RL-19/20/21 freeze ThreadReachabilityFacts. The existing RcAtomicity field on RcInc/RcDec is transitional physical vocabulary and must be retired from the shared artifact once both planners consume the new fact map.

    Cross-thread or unknown evidence produces PotentiallyShared (RL-20), proven thread-local evidence produces Confined (RL-19), and the closed-program condition produces all-applicable Confined facts (RL-21). VM, LLVM, native, compiled-WebAssembly, and JIT planners consume the same exact map; ReprPlan::rc_strategy() cannot override it.

    • Physical coalescing mode fence (supersedes TPR-06-1-codex’s carrier-specific shape): AIMS coalesces logical additional-credit and release events without an atomicity field. A physical optimizer may combine actions only after layout selection and must keep synchronization mode in its coalescing key. Add a negative pin showing that actions with incompatible physical RcSync plans are not merged. Do not preserve RcAtomicity in AIMS solely to service this backend optimization.
  • Tests (relocated IN from §06.3 per routing.md §4 — the NonAtomic-SELECTION tests require this section’s selecting backend to exercise; §06.3 keeps only the carrier-default SITE pins): per tests.md §Matrix Testing Rule semantic + negative pairing —

    • Cross-thread escape produces PotentiallyShared; each physical plan either selects a race-safe mechanism or is rejected. Pin evaluator/VM/LLVM debug/release/AOT behavior and cleanup parity. Use an FFI pointer export while the channel-send frontend path remains unavailable, retaining the skipped channel companion.
    • Intra-thread Sendable produces Confined; negative pin: reverting RL-19 fails the fact test, while a conservative atomic physical plan remains legal.
    • Program with no spawn/channel + no FFI exporting Ori-managed pointers (per RL-21) freezes all-applicable Confined facts; separate projection pins prove the compiled plan selects non-atomic RC and the session-confined VM uses no synchronization it does not need.
  • /tpr-review passed — independent review found no critical or major issues (or all findings triaged)

  • /impl-hygiene-review passed — hygiene review clean. MUST run AFTER /tpr-review is clean.

  • Subsection close-out (10.2) — MANDATORY before starting the next subsection. Run /improve-tooling retrospectively on THIS subsection’s debugging journey (per .claude/skills/improve-tooling/SKILL.md “Per-Subsection Workflow”): which diagnostics/ scripts you ran, where you added dbg!/tracing calls, where output was hard to interpret, where test failures gave unhelpful messages, where you ran the same command sequence repeatedly. Forward-look: what tool/log/diagnostic would shorten the next regression in this code path by 10 minutes? Implement improvements NOW (zero deferral) and commit each via SEPARATE /commit-push using a valid conventional-commit type (build(diagnostics): ... — surfaced by section-10.2 retrospectivebuild/test/chore/ci/docs are valid; tools(...) is rejected by the lefthook commit-msg hook). Mandatory even when nothing felt painful. If genuinely no gaps, document briefly: “Retrospective 10.2: no tooling gaps”. Update this subsection’s status in section frontmatter to complete.

  • /sync-claude section-close doc sync — verify Claude artifacts across all section commits. Map changed crates to rules files, check CLAUDE.md, canon.md. Fix drift NOW.

  • Repo hygiene check — run diagnostics/repo-hygiene.sh --check and clean any detected temp files.


10.3 Static Thread-Reachability Fence

File(s): AIMS verification plus shared executable validation

The production fact is static: a value that may cross a thread boundary is PotentiallyShared for its whole realized lifetime. No runtime transition or representation-owned reclassification exists.

Physical plans may choose different sufficient mechanisms without changing the fact.

  • Verify every channel send, spawn capture, and FFI-export path freezes PotentiallyShared for the transferred allocation.

  • Treat incomplete/unknown provenance as PotentiallyShared; never insert a late backend-local re-analysis.

  • Reject a VmLayoutPlan or CompiledLayoutPlan whose synchronization capability is weaker than the exact bound fact.

  • Require each adapter to preserve its validated mechanism across allocation, retain, release, COW, and drop. VM and compiled mechanisms need not be identical.

  • Keep dynamic mode switching outside this plan; it would require a new proved calculus rule, header protocol, and measured justification.

  • /tpr-review passed — independent review found no critical or major issues (or all findings triaged)

  • /impl-hygiene-review passed — hygiene review clean. MUST run AFTER /tpr-review is clean.

  • Subsection close-out (10.3) — MANDATORY before starting the next subsection. Run /improve-tooling retrospectively on THIS subsection’s debugging journey (per .claude/skills/improve-tooling/SKILL.md “Per-Subsection Workflow”): which diagnostics/ scripts you ran, where you added dbg!/tracing calls, where output was hard to interpret, where test failures gave unhelpful messages, where you ran the same command sequence repeatedly. Forward-look: what tool/log/diagnostic would shorten the next regression in this code path by 10 minutes? Implement improvements NOW (zero deferral) and commit each via SEPARATE /commit-push using a valid conventional-commit type (build(diagnostics): ... — surfaced by section-10.3 retrospectivebuild/test/chore/ci/docs are valid; tools(...) is rejected by the lefthook commit-msg hook). Mandatory even when nothing felt painful. If genuinely no gaps, document briefly: “Retrospective 10.3: no tooling gaps”. Update this subsection’s status in section frontmatter to complete.

  • /sync-claude section-close doc sync — verify Claude artifacts across all section commits. Map changed crates to rules files, check CLAUDE.md, canon.md. Fix drift NOW.

  • Repo hygiene check — run diagnostics/repo-hygiene.sh --check and clean any detected temp files.


10.4 Completion Checklist

Test matrix for §10 (write failing tests FIRST, verify they fail, then implement):

Program patternFrozen AIMS factPhysical-projection pin
Single-threaded program with many ownership eventsConfinedOptimized compiled/VM plan variants select thread-confined bookkeeping; a conservative atomic variant remains valid and is measured separately
Closed program with no thread boundary or FFI pointer exportAll applicable sites ConfinedOptimized plan variants show zero unnecessary synchronization; validity does not require every sufficient plan to choose the same mechanism
Multi-threaded: spawn() captures a listCaptured list PotentiallyShared; closure-local list ConfinedSplit race-safe/thread-confined mechanisms in both plans
chan.send(value) — value crosses channelvalue is PotentiallyShared for its realized lifetimeA thread-confined physical plan is rejected
Value created after spawn() in spawned closureConfined to that workerMay use thread-confined mechanics despite the program containing threads
Bounded count + confined valuebounded sharing + ConfinedCompiled plan may combine narrow and non-atomic storage; VM chooses independently
Single-thread decrement to zeroConfinedAtomic and thread-confined plan variants have identical drop behavior
  • Write failing test matrix BEFORE implementation (verify tests fail with current all-atomic codegen)
  • Single-threaded programs freeze Confined; every admitted physical plan selects a sufficient mechanism. Optimized plan variants avoid unnecessary synchronization, while conservative atomic variants remain legal reference projections.
  • Multi-threaded programs freeze PotentiallyShared only for reachable shared values
  • Channel sends force the shared AIMS fact to PotentiallyShared
  • Spawn captures force the shared AIMS fact to PotentiallyShared
  • Width-specific non-atomic variants: ori_rc_inc_nonatomic_i8, ori_rc_dec_nonatomic_i8, ori_rc_inc_nonatomic_i16, ori_rc_dec_nonatomic_i16 (combines with §09)
  • Add semantic pin test: a single-threaded program produces ZERO atomic RC operations in LLVM IR (all ops are ori_rc_*_nonatomic). This test can ONLY pass with thread-local analysis enabled.
  • Debug builds assert on RC-mode mismatches (atomic API used on non-atomic allocation or vice versa)
  • Non-atomic RC operations are measurably faster (benchmark ≥ 20% improvement in RC-heavy workloads)
  • Evaluator, VM, LLVM debug/release/AOT, and affected native/WASM/JIT paths preserve behavior and cleanup; dual-exec-verify.sh supplies only the current evaluator/LLVM leg
  • Extend diagnostics/valgrind-aot.sh to accept an optional --tool=helgrind passthrough flag:
    • Add --helgrind flag to the script: when present, pass --tool=helgrind --fair-sched=yes to valgrind instead of --tool=memcheck
    • This is a concrete shell script change, not “invoke manually”
    • File: diagnostics/valgrind-aot.sh (modify the valgrind invocation line)
  • Run helgrind on AOT binaries compiled from multi-threaded Ori programs (channel + spawn patterns): ./diagnostics/valgrind-aot.sh --helgrind tests/valgrind/threads/
  • Create tests/valgrind/threads/ directory with at minimum:
    • thread_local_only.ori — single-threaded program with many RC operations → no helgrind races
    • channel_send.ori — program that sends values through a channel → helgrind must find no races
  • ./test-all.sh green
  • ./clippy-all.sh green
  • ./diagnostics/dual-exec-verify.sh passes as the evaluator/LLVM leg of the broader matrix
  • /tpr-review passed — independent Codex review found no critical or major issues (or all findings triaged)
  • /impl-hygiene-review passed — implementation hygiene review clean (phase boundaries, SSOT, algorithmic DRY, naming). MUST run AFTER /tpr-review is clean.
  • /improve-tooling retrospective completed — MANDATORY at section close, after both reviews are clean. Reflect on the section’s debugging journey (which diagnostics/ scripts you ran, which command sequences you repeated, where you added ad-hoc dbg!/tracing calls, where output was hard to interpret) and identify any tool/log/diagnostic improvement that would have made this section materially easier OR that would help the next section touching this area. Implement every accepted improvement NOW (zero deferral) and commit each via SEPARATE /commit-push. The retrospective is mandatory even when nothing felt painful — that is exactly when blind spots accumulate. See .claude/skills/improve-tooling/SKILL.md “Retrospective Mode” for the full protocol.

Exit Criteria: AIMS freezes one validated ThreadReachability fact per applicable allocation and every physical planner proves its mechanism satisfies that exact fact without re-analysis.

Single-threaded benchmarks show zero unnecessary synchronization in VM traces and compiled IR where RL-21 applies; thread-boundary cases remain race-safe everywhere. The two projections may use different encodings.

Behavior, cleanup, race, and leak gates are green. Performance evidence reports speed and memory across mixed RC-heavy workloads rather than only LLVM instruction counts.


10.R Third Party Review Findings

  • [TPR-10-001][major] section-10-thread-local-arc.md:117-148Non-atomic RC has no debug-mode safety net; analysis wrong → silent UB. The ori_rc_inc_nonatomic / ori_rc_dec_nonatomic functions use plain loads/stores (lines 120, 124: *rc_ptr). If thread escape analysis (§08+§10.1) is unsound for any value, concurrent access produces data race UB. The plan acknowledges this risk (line 111) but proposes no runtime fallback — only helgrind testing as a detection tool. No mechanism exists to verify at runtime that a value classified as thread-local is actually single-threaded. Action: Add a debug-mode #[cfg(debug_assertions)] per-allocation flag that records the RC mode (atomic vs non-atomic). Assert on mismatched access (e.g., ori_rc_inc called on an allocation marked non-atomic). Zero cost in release builds. This catches analysis bugs during development before they become silent data races in production. Consensus: 3/3 reviewers.

HISTORY

  • 2026-07-14 — backend-neutral calculus correction: supersedes both the earlier ori_repr::escape::ThreadLocality proposal and the intermediate “AIMS emits RcAtomicity” correction. AIMS freezes only ThreadReachability; VmLayoutPlan and CompiledLayoutPlan independently select and validate synchronization, header, width, and instruction mechanics. The shipped RcAtomicity enum is a migration carrier, not final calculus vocabulary.
  • 2026-06-02 — RL-19/20/21 NonAtomic-selecting dispatch + its NonAtomic-selection tests relocated IN from plans/aims-burden-tracking/section-06-phase7-mechanical-lowering.md §06.3 (routing.md §4): §06.3 delivered the RcAtomicity { Atomic, NonAtomic } carrier SITE on the RcInc/RcDec ArcInstr variants (compiler/ori_arc/src/ir/instr.rs + ir/repr.rs), defaulted to Atomic at every construction site (reproducing the shipped unconditionally-atomic runtime RC primitives bit-for-bit). The dispatch that POPULATES RcAtomicity::NonAtomic cannot live in §06.3 — it requires §10.1’s ThreadLocality thread-escape analysis (compiler/ori_repr/src/escape/thread.rs), §10.2’s non-atomic runtime (ori_rc_inc_nonatomic/ori_rc_dec_nonatomic), and §10.2’s atomic-vs-non-atomic codegen selection (emit_atomic_rc/emit_nonatomic_rc), all owned HERE. Populating NonAtomic in §06.3 without §10’s backend would be an inert no-op (the carrier ignored by the unconditionally-atomic codegen). Per routing.md §4 MOVE-the-item discipline: §10.2 gains the carrier-population checkbox + the 3 NonAtomic-selection tests (cross-thread-escape FFI-export → Atomic; intra-thread Sendable → NonAtomic; whole-program RL-21 → all NonAtomic). The channel-send #skip companion is anchored to BUG-02-037 (channel construction channel<T>(buffer:) emits E2040 at the frontend — filed 2026-06-02; the Producer<T>/Consumer<T> channel-send thread-escape path is blocked until E2040 resolves, so the primary cross-thread exercise is FFI pointer export per RL-21). §06.3 retains only the carrier-default SITE pins; §06.3 checkboxes 1+2 + SITE-tests are [x]-DONE. The §10.4 test matrix already covers the whole-program + channel-send + spawn-capture rows; the relocated §10.2 tests reference them.