Draft Full sparse (should talk about functions)#1814
Closed
bjjwwang wants to merge 7 commits intoSVF-tools:masterfrom
Closed
Draft Full sparse (should talk about functions)#1814bjjwwang wants to merge 7 commits intoSVF-tools:masterfrom
bjjwwang wants to merge 7 commits intoSVF-tools:masterfrom
Conversation
…overlay Phase A adds three per-kind MSSA def-anchor transfers (MssaPhi, FormalIn, ActualOut) in AbstractStateManager, filling trace[N]._addrToAbsVal[obj] at every ICFG node that hosts a matching SVFG def-node. Transfers use only SVFG's public APIs (getVFGNodes, IndirectSVFGEdge::getPointsTo, MRVer::getMR, SVFUtil::isExtCall) and commit with fill-empty-only semantics so merge's branch-refined values are preserved. Phase B gates _addrToAbsVal copy in AbstractState::joinWith under Sparse mode, routes ObjVar reads through getDefSiteOfObjVar, and adds a path-refined overlay (pathRefinedAt) populated by mergeStatesFromPredecessors from per-edge narrowingDelta returned by isCmpBranchFeasible. Overlay propagates via per-pred snapshot (inherit + tighten with delta) combined across preds by intersection-with-join, then committed into the successor with meet for WTO monotonicity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the dominant loop/recursion failure class (50/115 tests) in full-sparse mode. Two orthogonal bugs in the cycle machinery: 1. Overlay (pathRefinedAt) commit used meet-across-iterations, trapping the entry at the earliest tightest narrowing. Under a widening cycle, reads of refined ObjVars stopped converging because meet(widenedBase, staleTightOverlay) stayed pinned to the tight value. Switched to join-across-iterations so overlay grows with the base. Narrowing-phase tightening is still achieved via isCmpBranchFeasible re-producing per-iter narrowingDelta folded by per-pred meet. 2. updateAbstractValue(ValVar) in Sparse routed writes to the caller- supplied node instead of the var's def-site. Cycle scatter (widenCycleState in SparseAbstractInterpretation) passes cycle_head but expects the write to land at each body ValVar's def-site, as in SemiSparse. Extended the existing SemiSparse branch to also cover Sparse. Also switches the fast-path gate in getAbstractValue(ValVar) from '!semiSparse' to 'denseOnly' so Sparse reads go through the def-site pull (matching SemiSparse) rather than returning a stale current-node ValVar cached by an earlier transfer. Test count drops from 115 failures to 65. Dense and SemiSparse both stay at 352/352. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
APOUT SVFG nodes are hosted at the RetICFGNode of a call-pair, not the CallICFGNode, per SVF's MSSA node placement. The Phase A wiring ran runActualOUTTransferAt on the CallICFGNode and walked its VFG nodes — which hold AParm/APIN but never APOUT — so the transfer never fired and no reader ever saw a reaching value at the call's def-anchor. Even when the transfer did run (e.g., if we committed at the callNode anyway), WTO processes the retNode right after the callNode and its own merge would clear any _addrToAbsVal we had written into trace [callNode] state copied forward. Fix: the driver dispatches runActualOUTTransferAt when the current WTO node is a RetICFGNode (post-merge hook), and the transfer itself scans retNode->getVFGNodes() and commits to trace[retNode]. Matches the invariant that each def-anchor is filled on its own ICFG visit by exactly one filler. Cuts funcall-ref / out-param cross-function writes from failing to passing; suite moves from 65 -> 58 failing. Dense + SemiSparse stay 352/352. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small fixes for the ObjVar read path in Sparse mode, uncovered by triaging array-init assert tests: 1. globalICFGNode fallback in getAbstractValue(ObjVar*, node). Statically-initialised globals and const-init arrays are populated into trace[globalICFGNode] during program setup but have no intra-procedural MSSA def-anchor. If getDefSiteOfObjVar returns nothing, consult globalICFGNode before falling through to top. 2. Null-def-site fallback for ValVar reader. Some load-produced SSA ValVars come back with getICFGNode() == nullptr (IR-lowering artifact). Sparse was routing to a null def-site and returning top; now falls back to the current node's slot (where the load transfer wrote the value). 3. AbsExtAPI::handleMemcpy source read routed through the Sparse- aware reader. The inline `as.load(srcAddr)` only finds a value if `_addrToAbsVal` at the call site has been bulk-populated by merge — which never happens in Sparse. Route via mgr->getAbstractValue(srcObjVar, node) so the global-const initializer values are found via the def-anchor chain. These together clear the "RHS read returns top -> stores write top" chain for const-init array loads. Suite moves from 58 -> 57 failing; the remainder is dominated by an Andersen-pts vs AE-pts-to mismatch on GEP'd arrays (the store's MSSA CHI is against a different obj id than the store's AE write target, so sparse's SVFG-routed reader at a downstream load cannot find the store's value). Dense papers over this via bulk _addrToAbsVal copy; fixing it in Sparse is out of scope for this pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When AE's runtime-computed pts-to disagrees with Andersen's static pts-to on a store (common on GEP'd arrays: AE sees the precise field-obj, Andersen gives a different / coarser obj), MSSA builds its reaching-def chain from Andersen's pts and a Sparse reader routed via getDefSiteOfObjVar asks for the Andersen obj-id — which AE's AE-pts-only write never touched. Dense papers over this via bulk _addrToAbsVal merge-copy. Sparse must explicitly mirror the store onto every obj in Andersen's pts. For already-present slots we join so an AE-precise strong-update survives as a lower bound. Needs the pta handle stored on AbstractStateManager; constructor was already given it but not retaining the pointer. Added as a private member. Fixes one nullptr_deref test (ExtAPI memcpy-like case where AE and Andersen disagreed on dst obj). Does not fix the array[constant-index] assert cluster (19 tests): there Andersen actually gives the wrong obj-id outright (says a+9 -> a[0]), so mirroring onto Andersen's pts writes to the wrong obj and MSSA's routing for the correct-index load still bypasses the store node. That is an Andersen imprecision / field-sensitivity gap, not a sparse routing bug. Suite moves from 57 -> 56 failing. Dense + SemiSparse stay 352/352. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the ObjVar read fallback chain in Sparse mode. When MSSA's getDefSiteOfObjVar routes to an anchor whose slot is empty (or routes wrong because Andersen disagrees with AE on the GEP'd obj-id), walk the ICFG in-edges back from the use-site until a predecessor's trace is found to hold the obj. Multiple reaching writers from distinct branches join into a single upstream value. Traversal stops past any latest-writer so join respects per-path semantics. Result is materialised at hereState for O(1) repeated reads. Motivation: AE computes GEP'd ObjVar ids at runtime via getGepObjAddrs(base, offset); for a constant offset the store and the corresponding load agree on the same ObjVar id, but MSSA's reaching-def chain is built from Andersen's static pts-to which can disagree. In Dense/Semi this is papered over by bulk _addrToAbsVal merge-copy. Sparse gated merge-copy, so we need an explicit fallback — but only when MSSA fails; for the common case the existing MSSA routing still fires first and pays zero walk cost. Cuts assert-suite failures 19 -> 5. Suite moves from 56 -> 42 failing (assert 5, overflow 15, recursion_from_overflow 15, recursion 4, nullptr 3). Dense + SemiSparse stay 352/352. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ICFG reverse-BFS fallback in getAbstractValue(ObjVar*, node)
with a flow-insensitive global gepOverlay map keyed by GepObjVar NodeID.
Why: AE precise-refines GEP fields (a[3], a[5], ...) into GepObjVar ids
that MSSA / Andersen do not track on the precise id. Sparse merge
skips _addrToAbsVal, so reads at downstream nodes cannot reach the
write through the per-node trace. The previous workaround was a
reverse BFS over ICFG in-edges, amortised O(1) via materialise-back
caching but ungainly and stale across WTO iterations.
This patch:
- Adds Map<NodeID, AbstractValue> gepOverlay to AbstractStateManager.
- storeValue folds every GepObjVar write into gepOverlay (join_with),
in addition to the existing AE-pts trace write and Andersen mirror.
- Reader looks up gepOverlay for GepObjVar before the
globalICFGNode fallback; BFS is removed.
Trade-off: gepOverlay is flow-insensitive, so a[3]=7; a[3]=11 joins to
[7,11] instead of strong-updating to 11. Empirically this loses one
test (BASIC_array_int_0) but recovers nine others that BFS missed,
net +8 passing tests. Memory drops slightly as the BFS
materialise-back side-effect is gone.
Also routes ObjVar reads in AbsExtAPI handlers (strRead, getStrlen,
handleMemset) through mgr->getAbstractValue(ObjVar, node) instead of
as.load(addr), so detector FP/FN reasoning sees sparsity-aware values.
Results on 289-file benchmark (assert + overflow + nullptr + recursion):
Dense: 352/352, ES_Loc=2,320,904 ES_Var=4,200,835
Semi-Sparse: 352/352, ES_Loc=2,320,832 ES_Var= 106,608
Full-Sparse: 332/352, ES_Loc= 42,323 ES_Var= 98,622 (-98.2% Loc)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Description — Full-Sparse AE for SVF
TL;DR
Add a third sparsity mode to SVF's abstract execution. Replace dense's "copy entire
_addrToAbsValat every ICFG node" with three SSA-anchored transfers + two AE-side overlays. Memory drops 98% versus Dense; 332/352 tests pass; Dense and Semi-Sparse remain at 352/352.Why Full-Sparse
Dense AE's
mergeStatesFromPredecessorsdoes two unrelated jobs at once:trace[useNode]._addrToAbsVal[obj]is always populated, regardless of whether the reaching def is a store, a phi, a function entry, or a call return.Full-Sparse decouples the two. Values live at MSSA def-anchors only; reads route through
getDefSiteOfObjVar. Memory cost falls fromO(ICFG nodes × all ObjVars)toO(real def-anchors only).But once we kick out the bulk copy, three things Dense had implicitly stop working. The PR is essentially "rebuild those three things sparingly":
pathRefinedAttable for branches).trace[cycleHead]having every variable's current value because merge put them there. → ValVar pull-and-scatter at cycle heads + ObjVar via re-firing transfers (Section 3).1. Why three new transfer helpers?
The invariant they establish
MSSA produces exactly five kinds of ObjVar def-anchors:
Anchor SVFG node | ICFG host | Filler -- | -- | -- StoreVFGNode | store stmt | existing updateStateOnStore MSSAPHISVFGNode | block entry / cycle head | runMSSAPHITransferAt FormalINSVFGNode | FunEntryICFGNode | runFormalINTransferAt ActualOUTSVFGNode (normal callee) | RetICFGNode | runActualOUTTransferAt ActualOUTSVFGNode (ExtAPI callee) | CallICFGNode | existing AbsExtAPI handlerES_Loc_TOTAL(ObjVar storage) drops 98.2% versus Dense.ES_Var_TOTAL(ValVar storage) drops 97.7%.Dense and Semi-Sparse remain unchanged at 352/352 — none of the new mechanisms fire in those modes.
Out of scope (the 20 remaining failures)
recursive_afterrec: ActualOUT chain through self-recursive SCCs needs more iteration; tractable follow-up.pathRefinedAtoverlay needs to extend to ValVar pointers (if (cond) p = NULL; UNSAFE_LOAD(p);).BASIC_array_int_0: Phase D's flow-insensitive trade-off (won't fix without losing the simplicity).None indicate a soundness regression.
Files changed
pathRefinedAt,gepOverlay.gepOverlaywrite/read paths.mergeStatesFromPredecessors, modifiedisCmpBranchFeasiblesignature.joinWith.initCycleValVarsfor the bottom-up cycle ValVar set.No SVFG / VFG / ICFG / MSSA / Andersen header or behavioural changes.
PR Description — Full-Sparse AE for SVF TL;DR Add a third sparsity mode to SVF's abstract execution. Replace dense's "copy entire _addrToAbsVal at every ICFG node" with three SSA-anchored transfers + two AE-side overlays. Memory drops 98% versus Dense; 332/352 tests pass; Dense and Semi-Sparse remain at 352/352.Why Full-Sparse
Dense AE's mergeStatesFromPredecessors does two unrelated jobs at once:
Propagating store-produced values along ICFG edges (the legitimate dataflow job).
Materialising reaching values at every non-store SSA anchor as a side-effect of bulk state copy — phi nodes, function entries, call returns. This second job is what makes Dense's reader "just work": trace[useNode]._addrToAbsVal[obj] is always populated, regardless of whether the reaching def is a store, a phi, a function entry, or a call return.
Full-Sparse decouples the two. Values live at MSSA def-anchors only; reads route through getDefSiteOfObjVar. Memory cost falls from O(ICFG nodes × all ObjVars) to O(real def-anchors only).
But once we kick out the bulk copy, three things Dense had implicitly stop working. The PR is essentially "rebuild those three things sparingly":
Non-store SSA anchors (MSSAPHI / FormalIN / ActualOUT) need to be filled by someone, since merge no longer materialises them. → Three transfer helpers (Section 1).
Branch refinement (cmp-induced narrowing) and GEP-precision refinements were carried by merge's bulk copy. → Two AE-side overlays (Section 2 + the pathRefinedAt table for branches).
Cycle widening relied on trace[cycleHead] having every variable's current value because merge put them there. → ValVar pull-and-scatter at cycle heads + ObjVar via re-firing transfers (Section 3).
void runMSSAPHITransferAt(const ICFGNode* N);
void runFormalINTransferAt(const ICFGNode* N);
void runActualOUTTransferAt(const ICFGNode* N);
The invariant they establish
For every ICFG node N that getDefSiteOfObjVar may return, and every ObjVar o it anchors at N, there is exactly one filler that writes trace[N]._addrToAbsVal[o] during N's WTO visit, before any downstream reader observes it.
MSSA produces exactly five kinds of ObjVar def-anchors:
Anchor SVFG node ICFG host Filler
StoreVFGNode store stmt existing updateStateOnStore
MSSAPHISVFGNode block entry / cycle head runMSSAPHITransferAt
FormalINSVFGNode FunEntryICFGNode runFormalINTransferAt
ActualOUTSVFGNode (normal callee) RetICFGNode runActualOUTTransferAt
ActualOUTSVFGNode (ExtAPI callee) CallICFGNode existing AbsExtAPI handler
Without these three helpers, four out of five anchor kinds would be empty when the reader tries to read from them — and the entire sparse design collapses into "everything reads top".
What they share — one pattern, three flavours
runXxxTransferAt(N):
for each Xxx-kind VFG node D at N:
for each obj in D.MR.getPointsTo():
meet = ⊥
for each upstream CHI U of D (via SVFG indirect edges):
if trace[U.ICFGNode][obj] exists:
meet ⊔= trace[U.ICFGNode][obj]
commitTransferValue(trace[N], obj, meet) # fill-empty-only
The only thing that varies is how upstream CHIs are reached — and that boils down to one SVFG quirk: MU (use-annotation) nodes don't hold values, only CHI (def-annotation) nodes do. If an edge leads to a MU, walk one more indirect-edge hop.
Transfer Direct upstream MU intermediary? Hops
MSSAPHI indirect in-edges → operand CHIs no 1
FormalIN CallIndSVFGEdge → ActualIN (CallMU) → upstream CHI yes 2 (then fall back to globalICFGNode if no caller reached)
ActualOUT RetIndSVFGEdge → FormalOUT (RetMU) → callee CHI yes 2 (skip entirely if callee is ExtAPI)
Critical detail: commitTransferValue is fill-empty-only
The transfer only writes to slots merge left empty. If merge already wrote a value (which it can, when isCmpBranchFeasible narrowed the pred state in Dense/Phase-A), the transfer defers. Without this, transfer's un-refined upstream values would clobber merge's branch-refined precision and break four assert tests in early prototyping.
In the final design (Phase B+ where merge skips ObjVar copy entirely), the slot is always empty when the transfer runs and the transfer becomes the primary filler — but the fill-empty-only semantics stay as a safety property.
getPathRefinedAt
Map<const ICFGNode*, Map<NodeID, AbstractValue>>& getPathRefinedAt();
This is the public accessor for the branch refinement overlay — a separate concern from the three transfers but tied into the same reader. The overlay stores per-ObjVar narrowings that come from if (p < 5) style cmp branches, which MSSA does not model (cmp is not a def in SSA). It is populated by the modified mergeStatesFromPredecessors (folding each feasible edge's narrowingDelta from isCmpBranchFeasible) and consumed by getAbstractValue(ObjVar, useNode) via meet at read time.
Why we need to expose it: cycle handling (SparseAbstractInterpretation::widenCycleState) and external diagnostics need to inspect / preserve the overlay across iterations.
Map<NodeID, AbstractValue> gepOverlay;
The disconnect
AE precise-refines GEP fields at runtime via getGepObjAddrs(base, [low, high]):
int a[10];
a[3] = 7; // AE writes obj-id 42 (= GepObjVar(base=37, offset=3))
v = a[3]; // AE reads obj-id 42
But MSSA / Andersen are static — they only see the base obj arr (id 37) for these stores, not the field-precise GepObjVar id 42. Concretely:
getDefSiteOfObjVar(42, useNode) returns null because no SVFG indirect edge has 42 in its PointsTo set.
The globalICFGNode fallback for non-globally-initialised arrays returns nothing.
So a GepObjVar read in pure sparse mode would always return ⊤. Every array test would fail.
What we tried first and discarded
ICFG reverse BFS fallback: walk back from useNode via ICFG in-edges until we find a node whose trace holds obj 42. Worked correctly (324/352) but was an O(N) graph walk inside a function that should be O(1), with stale-cache issues across WTO iterations.
Route via base's MSSA def-site: call getDefSiteOfObjVar(baseObj, useNode), then read trace[defNode][gepId]. Triggered SVFG's hard assert(defSite == nullptr && "unique indirect definition"), because complex ICFG nodes (call sites, init nodes) host multiple VFG nodes referencing the base — multiple matches violate the API's unique-def invariant.
Selectively merge only GepObjVar ids: in joinWith, copy only ids whose SVFVar is a GepObjVar. Memory exploded to 2.24M Loc entries because struct field GepObjVars propagated densely to every node in their containing function.
The shipped solution
A flow-insensitive global map keyed by GepObjVar's NodeID (which uniquely encodes (base, offset) via SVFIR's canonical getGepObjVar). Single map, lives in AbstractStateManager.
storeValue(p, val, n):
... # existing trace + Andersen mirror writes
for o in pts(p):
if o is GepObjVar:
gepOverlay[o.id].join_with(val) # fold to the global map
getAbstractValue(ObjVar* var, ICFGNode* node):
... step 1-3 (local, MSSA, globalICFGNode) ...
if !base && var is GepObjVar: # step 4
if gepOverlay.has(var.id):
base = gepOverlay[var.id]
Why this works
Reader O(1): single Map::find, no graph walk.
No SVFG modification: doesn't touch the asserting API.
Unaffected by sparse merge gating: gepOverlay is a separate global structure, propagation isn't merge's job.
Cross-procedural for free: every GepObjVar write across all functions folds into the same map; reads anywhere find them.
Trade-off: flow-insensitive
GepObjVar values use join_with only — never strong-update. So:
a[3]=7; a[3]=11; assert(a[3]==11) reads [7, 11] (joined), not 11. One test regresses (BASIC_array_int_0).
a[3]=7; a[5]=11; assert(a[3]==7) still reads 7 (different keys). Works fine.
for (i...) a[i] = 0; a[k] = 99; assert(a[k]==99) reads [0, 99]. Loses precision in this pattern.
In our 352-test suite, net effect is +8 tests passing versus BFS — flow-insensitivity costs 1 test but the BFS version had its own staleness bugs in cycles that cost more.
Why GepObjVar can get away without widening
A standard concern with flow-insensitive join-only structures is termination. For interval domains it isn't a problem: the lattice has finite height (bounded by [-INF, INF]), and join_with only ever climbs the lattice. So gepOverlay naturally terminates without needing an explicit widen operator. Precision is sacrificed; soundness and termination are not.
Cycles are the place where everything sparse-related compounds. The challenge: WTO widening compares prev and cur snapshots at the cycle head, and Dense relies on those snapshots having every value via merge's bulk copy. Sparse's prev/cur snapshots come from trace[cycleHead], which doesn't store ValVars at all in sparse mode.
We solve ValVar and ObjVar with two different mechanisms.
3.1 ValVar — pull-and-scatter
In sparse, ValVars only live at their def-sites (var->getICFGNode()). They are never written to trace[cycleHead] by merge. So we must explicitly transport them in and out of the cycle head's snapshot.
Pre-analysis collects the cycle's ValVar set (PreAnalysis::initCycleValVars):
Map<const ICFGCycleWTO*, Set<const ValVar*>> cycleToValVars;
Bottom-up over the WTO: each cycle's ValVar set = LHS ValVars of every stmt in its singleton members + nested sub-cycles' sets + (if head is a FunEntryICFGNode) formal parameters. Computed once before AE runs; reused on every iteration.
Entering the cycle iteration (SparseAbstractInterpretation::getFullCycleHeadState):
AbstractState snap = base::getFullCycleHeadState(cycle); // ObjVars come from trace
snap.clearValVars();
for (const ValVar* v : preAnalysis->getCycleValVars(cycle)):
if (v->getICFGNode() && hasAbsValue(v, v->getICFGNode())):
snap[v->getId()] = getAbsValue(v, v->getICFGNode()) // pull from def-site
Exiting the cycle iteration (widenCycleState / narrowCycleState):
base::widenCycleState(prev, cur, cycle) // writes widened state to trace[cycleHead]
for (id, val) in trace[cycleHead].getVarToVal():
updateAbsValue(svfir->getSVFVar(id), val, cycleHead) // scatter back to def-site
Note that updateAbsValue in sparse mode internally routes the write to the var's def-site (not literally cycleHead) — that's how the next iteration's body reads pick up the widened value.
3.2 ObjVar — no scatter, just re-fire runMSSAPHITransferAt
ObjVars do live in trace[cycleHead]._addrToAbsVal in sparse mode, populated each iteration by runMSSAPHITransferAt(cycleHead) (which joins from operand def-sites = entry def + back-edge def per latch). This means:
The widen-set for ObjVars is implicit — it's exactly MR.getPointsTo() of the MSSAPHIs at cycle head, computed dynamically each iteration. No pre-collection needed.
No scatter needed — values stay in trace[cycleHead]. The next iteration's body reads route via getDefSiteOfObjVar(o, useNode), which (for objs whose defs reach via the cycle's MSSAPHI) returns cycleHead, and the read finds the widened value there.
Per-iteration timeline:
iter k:
prev = getFullCycleHeadState(cycle) # ObjVars: copy of trace[cycleHead] from iter k-1
# ValVars: pulled from def-sites
mergeStatesFromPredecessors(cycleHead) # sparse: clears _addrToAbsVal
runMSSAPHITransferAt(cycleHead) # re-fills _addrToAbsVal:
# for each obj in MR.PointsTo():
# join over operand def-sites
handleICFGNode body # body stmts may write to other nodes
cur = getFullCycleHeadState(cycle) # ObjVars: trace[cycleHead] just refilled
# ValVars: pulled again
next = prev.widening(cur) # widens both _varToAbsVal and _addrToAbsVal
trace[cycleHead] = next
scatter ValVars from trace[cycleHead] back to their def-sites
ObjVars stay in trace[cycleHead] for iter k+1
3.3 Recursion
WTO treats recursion SCCs as cycles, so the same machinery applies. One subtlety: runActualOUTTransferAt at a recursive call's RetICFGNode walks RetIndSVFGEdge → FormalOUT → IndirectSVFGEdge to find callee CHIs, which may include the same call's ActualOUT (a self-loop). First iteration finds the slot empty (just cleared by merge); WTO iterates the SCC and the slot fills in subsequent rounds. fill-empty-only ensures the recursion's skipRecursionWithTop mode (when enabled) writes ⊤ to exactly the slot the reader will consult, and the transfer doesn't subsequently overwrite that ⊤.
3.4 GepObjVar in cycles
gepOverlay participates in cycles trivially: every iteration's stores fold their values into the same global map via join_with. The lattice is bounded so termination is automatic. There is no widening on gepOverlay and none is needed — the worst case is intervals saturating to [-INF, INF], which terminates the WTO loop.
3.5 The widening monotonicity bug worth noting
An earlier draft of pathRefinedAt's cross-iteration commit used meet. Under widening, this trapped overlay entries at iteration 1's tightest narrowing, while the base def-anchor value widened around them. Reads computed meet(widened_base, stale_tight_overlay) = stale_tight_overlay, and the cycle never converged.
Fix: cross-iteration commit uses join. The per-iteration tightening still happens because isCmpBranchFeasible recomputes narrowingDelta each round and the per-pred snapshot folds delta via meet — that part is correct. Only the cross-iteration step needed to climb the lattice with the base value, not against it. Single-line change in mergeStatesFromPredecessors recovered 50/115 failing tests at the time.
Empirical results (289-file benchmark)
Mode ES_Loc_TOTAL ES_Var_TOTAL Tests
Dense 2,320,904 4,200,835 352/352
Semi-Sparse 2,320,832 106,608 352/352
Full-Sparse 42,323 98,622 332/352
ES_Loc_TOTAL (ObjVar storage) drops 98.2% versus Dense. ES_Var_TOTAL (ValVar storage) drops 97.7%.
Dense and Semi-Sparse remain unchanged at 352/352 — none of the new mechanisms fire in those modes.
Out of scope (the 20 remaining failures)
4 × recursive_afterrec: ActualOUT chain through self-recursive SCCs needs more iteration; tractable follow-up.
6 × detector FP/FN under branch refinement: pathRefinedAt overlay needs to extend to ValVar pointers (if (cond) p = NULL; UNSAFE_LOAD(p);).
5 × ExtAPI / scanf / switch / memcpy edge cases: middling effort.
1 × BASIC_array_int_0: Phase D's flow-insensitive trade-off (won't fix without losing the simplicity).
4 × very complex CFG (goto + nested loops + while-true exit): borderline cases.
None indicate a soundness regression.
Files changed
svf/include/AE/Svfexe/AbstractStateManager.h: public API for the three transfers, pathRefinedAt, gepOverlay.
svf/lib/AE/Svfexe/AbstractStateManager.cpp: transfer implementations, sparsity-aware reader, gepOverlay write/read paths.
svf/lib/AE/Svfexe/AbstractInterpretation.cpp: driver hooks for the three transfers, modified mergeStatesFromPredecessors, modified isCmpBranchFeasible signature.
svf/lib/AE/Core/AbstractState.cpp: sparsity gate in joinWith.
svf/lib/AE/Svfexe/SparseAbstractInterpretation.cpp: cycle ValVar pull-and-scatter.
svf/lib/AE/Svfexe/PreAnalysis.cpp: initCycleValVars for the bottom-up cycle ValVar set.
svf/lib/AE/Svfexe/AbsExtAPI.cpp: ObjVar reads in ExtAPI handlers go through the sparsity-aware reader.
No SVFG / VFG / ICFG / MSSA / Andersen header or behavioural changes.