Explainer 9 of 11

Sweeps: a fixpoint with a budget that says when it ran out

Walk the whole program in reverse postorder a fixed number of times. Stop early when a sweep changes nothing. If the sweeps run out first, say so on every result.

Where this shows up in reverse engineering

A whole-corpus run is as slow as its slowest program. When the goal is one specific answer — here, which programs each CALL WS-PGM or EXEC CICS XCTL/LINK PROGRAM(WS-...) can reach — a few tangled programs should not hold the rest back, and they should not quietly return less than they found either.

The problem

Cobble's default walk is the worklist from fixpoints and worklists. Its budget is --iteration-factor: that many dequeues per CFG node (150 by default), after which the walk stops and publishes what it has, unsettled. On the large corpus the worklist did finish every program. But the last three programs took 1,404 s, 2,324 s and 4,434 s. The run spent over an hour waiting on one program.

A worklist is also hard to watch. Nothing in it counts down: the queue grows and shrinks, so the best a progress line can do is estimate from how fast the queue drains. You cannot tell in advance how much work a program will need, or how far along it is.

What is wanted is a walk whose cost is known before it starts, which still gives the exact answer when it has time to, and which marks its results when it does not.

The intuition

One sweep visits every node the entry reaches, once, in reverse postorder. At each node it applies the statement to the node's entry state and pushes the result into each successor's entry state, joining it with whatever is there.

A forward chain is carried in one sweep, however long. Reverse postorder puts every node after all the nodes that lead into it, except along back edges. So when a sweep reaches a node, everything that flows into it along forward edges has already arrived. MOVE 'X' TO WS-A, then PERFORM P1, where P1 does MOVE WS-A TO WS-B and PERFORM P2, and P2 does MOVE WS-B TO WS-C; then, back in the caller, MOVE WS-C TO WS-D and GOBACK. Every one of those reads finds its definer in a single sweep. On a program like that with no back edge, cobble runs one sweep and calls the member settled — there is nothing a second sweep could add.

A loop needs a second sweep. A loop's test comes before its body in reverse postorder, because the test is how the body is reached. The body's write gets back to the test only along the back edge, at the end of the body. By then the sweep has already visited the test and pushed its state to the code after the loop. So in the first sweep, every reader after the loop sees only what was true before the loop ran. In the second sweep the test starts with the body's write in its state and hands it on. A value has to go round a back edge once before it reaches the code after the loop. That is why two sweeps is the useful floor.

A sweep that changes nothing proves the fixpoint. If a whole sweep leaves every entry state as it was, the next sweep would too: the walk is at the fixpoint, and the answer is exactly the worklist's. The member is settled. If the budget of sweeps runs out while states are still growing, the member is unsettled.

Unsettled means missing, never wrong. Every transfer and the join are monotone (see the rule), so a state stopped early is a subset of the fixpoint's. Every flow read off it is a real flow; every value in a set is a value the field can really hold. What may be absent is a flow or a value that needed more sweeps. Cobble says so: every dynamic call target and every jump in an unsettled member carries the reason incomplete-walk (see value sets for how reasons work), and the member is counted and named as unsettled in the run's summary.

Carry less on each sweep. With --call-targets-only, cobble first runs a backward liveness walk over each program. It starts from the bytes the answer needs: each dynamic call target's operand, each area handed to another program (only the bytes some program reads, with --narrow-areas), and the LINKAGE areas read at an exit or a CICS RETURN. Working backward, a byte is live on entry to a node if some later node needs it and nothing in between certainly overwrites it; a write into a live byte makes its source bytes live. The forward walk then drops every state entry that overlaps nothing live at the node it is handed to. The walk still visits the same nodes, but each visit carries only what the dynamic targets and handed-over areas depend on.

The example

A loop body writes WS-NEXT. A LINK after the loop reads it.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGA.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-REC.
           05  WS-NEXT          PIC X(8) VALUE 'PROGB'.
           05  WS-I             PIC 9(2) VALUE 0.
       PROCEDURE DIVISION.
       MAIN-PARA.
           PERFORM PICK-PARA UNTIL WS-I > 3.
           EXEC CICS LINK PROGRAM(WS-NEXT) END-EXEC.
           EXEC CICS RETURN END-EXEC.
       PICK-PARA.
           MOVE 'PROGC' TO WS-NEXT.
           ADD 1 TO WS-I.

Cobble's control-flow graph for this program has 13 reachable nodes. Besides one node per statement, it has a label at the start of each paragraph, a separate node for the loop's test, and for each EXEC CICS a #raise node (did the command raise a handled condition?) and a #dispatch node (where a handler would take it). The test's FALSE arm goes to the PERFORM and into PICK-PARA; its TRUE arm goes to the LINK. The end of PICK-PARA returns to the test: that is the one back edge.

The reverse postorder cobble computes for it, from depth-first search taking the test's FALSE arm first:

MAIN-PARA#labelMAIN-PARA/0#loop#testMAIN-PARA/1MAIN-PARA/1#raiseMAIN-PARA/2MAIN-PARA/2#raiseMAIN-PARA/2#dispatchMAIN-PARA/1#dispatchMAIN-PARA/0PICK-PARA#labelPICK-PARA/0PICK-PARA/1PICK-PARA#end

Here the code after the loop comes ahead of the body. The loop on the traversal page put the body first, because its search took the body's arm first. Either way the test comes before both, and that is what decides how many sweeps the loop needs.

The tables below show, for each node, the values WS-NEXT can hold on entry. PROGB is the VALUE clause; PROGC is written by PICK-PARA/0.

Step by step

The sweep walk on PROGA, with a budget of 10 (4 steps)
  1. Only the entry node has a state: the program's starting image, in which WS-NEXT holds its VALUE. The order above is fixed before the first sweep, so each sweep is exactly 13 visits.

    Entry state of MAIN-PARA#label

    fieldcan hold
    WS-NEXT{PROGB}
  2. Sweep 1. The test is visited second and pushes {PROGB} to both arms, so the LINK (MAIN-PARA/1) gets {PROGB}. The body runs last: PICK-PARA/0 moves 'PROGC' in. At the very end, PICK-PARA#end pushes {PROGC} back along the back edge, and the test's entry state grows to {PROGB, PROGC}. The test was already visited this sweep, so nothing after it sees that yet. Every node got a state for the first time, so the sweep changed something: not settled.

    Entry states after sweep 1: what WS-NEXT can hold

    nodestatementWS-NEXT
    MAIN-PARA#labelparagraph start{PROGB}
    MAIN-PARA/0#loop#testUNTIL WS-I > 3{PROGB, PROGC}
    MAIN-PARA/1LINK PROGRAM(WS-NEXT){PROGB}
    MAIN-PARA/1#raiseLINK raised?{PROGB}
    MAIN-PARA/2RETURN{PROGB}
    MAIN-PARA/2#raiseRETURN raised?{PROGB}
    MAIN-PARA/2#dispatchhandler{PROGB}
    MAIN-PARA/1#dispatchhandler{PROGB}
    MAIN-PARA/0PERFORM PICK-PARA{PROGB}
    PICK-PARA#labelparagraph start{PROGB}
    PICK-PARA/0MOVE 'PROGC' TO WS-NEXT{PROGB}
    PICK-PARA/1ADD 1 TO WS-I{PROGC}
    PICK-PARA#endreturn to the test{PROGC}

    If the budget were 1, the walk would stop here. The LINK's target row

    candidates: PROGBreasons: incomplete-walk
  3. Sweep 2. The test now starts with {PROGB, PROGC} and pushes it to both arms. The LINK sees PROGC for the first time, and so does everything after the test, including the body's own entry. Nine entry states grew, so this sweep changed something too: still not settled, even though the LINK's value set is now complete.

    Entry states after sweep 2 (amber: grew in this sweep)

    nodestatementWS-NEXT
    MAIN-PARA#labelparagraph start{PROGB}
    MAIN-PARA/0#loop#testUNTIL WS-I > 3{PROGB, PROGC}
    MAIN-PARA/1LINK PROGRAM(WS-NEXT){PROGB, PROGC}
    MAIN-PARA/1#raiseLINK raised?{PROGB, PROGC}
    MAIN-PARA/2RETURN{PROGB, PROGC}
    MAIN-PARA/2#raiseRETURN raised?{PROGB, PROGC}
    MAIN-PARA/2#dispatchhandler{PROGB, PROGC}
    MAIN-PARA/1#dispatchhandler{PROGB, PROGC}
    MAIN-PARA/0PERFORM PICK-PARA{PROGB, PROGC}
    PICK-PARA#labelparagraph start{PROGB, PROGC}
    PICK-PARA/0MOVE 'PROGC' TO WS-NEXT{PROGB, PROGC}
    PICK-PARA/1ADD 1 TO WS-I{PROGC}
    PICK-PARA#endreturn to the test{PROGC}

    If the budget were 2, the walk would stop here. The LINK's target row

    candidates: PROGB, PROGCreasons: incomplete-walk
  4. Sweep 3. Every node produces the same state it did in sweep 2. The sweep changed nothing, so the walk stops with 7 sweeps of the budget unused. The member is settled, and the doubt comes off.

    Entry states after sweep 3

    nodeWS-NEXT
    MAIN-PARA#label{PROGB}
    PICK-PARA/1, PICK-PARA#end{PROGC}
    the other 10 nodes{PROGB, PROGC}

    The LINK's target row

    candidates: PROGB, PROGCreasons: none

The same program under each budget, as cobble reports it. "Visits" is sweeps times the 13 reachable nodes for a sweep walk, and dequeues for the worklist.

walksweeps runsettledvisitsLINK candidatesreasons
--sweeps 11no13PROGBincomplete-walk
--sweeps 22no26PROGB, PROGCincomplete-walk
--sweeps 33yes39PROGB, PROGCnone
--sweeps 103yes39PROGB, PROGCnone
worklist—yes24PROGB, PROGCnone

On a program this small the worklist does fewer visits. What the sweep buys is the bound: a budget of N costs at most N × 13 visits here, known before the walk starts, and its progress is a fraction of a known whole.

With --call-targets-only, the liveness walk finds that only WS-NEXT matters: it is the LINK's operand, and WS-I feeds only the loop test. Cobble's log for the member reads 1 of 2 extents tracked: 2 written ranges, of which 1 is relevant. The worklist then needs 15 dequeues instead of 24, because changes to WS-I no longer re-queue anything. The sweep walk still visits 13 nodes per sweep and settles on sweep 3, but each visit carries only WS-NEXT. The LINK's candidates are the same in every case.

The rule

order = reverse postorder of the nodes the entry reaches
one sweep: for n in order: after = transfer_n(in(n));
           for each successor s: in(s) = in(s) ⊔ keep_s(after)
repeat sweeps until a sweep changes no in(n), or N sweeps have run
settled = the last sweep changed nothing, or the graph has no back edge
unsettled ⇒ in(n) ⊆ fixpoint in(n) for every n; every target and jump gets incomplete-walk
keep_s = everything, or with --call-targets-only only entries overlapping what is live at s

A value written in a loop body reaches a reader after the loop on sweep 2. Each further step it has to take backward against the order — around another back edge, or to a copy earlier in the same body — costs one more sweep. Proving the result costs one more sweep than finding it, except on a graph with no back edge, where the first sweep is the fixpoint.

What goes wrong

In cobble

The fixpoint (cobble-dataflow-fixpoint) runs the worklist by default, capped at --iteration-factor × nodes dequeues (default 150). --sweeps N runs reverse-postorder sweeps instead, until one changes nothing or N are spent. The two flags cannot be combined, and --sweeps 0 is rejected. A member with no back edge is settled after one sweep.

The log says settled in N sweeps or NOT SETTLED after N sweeps for each member. The closing warning names every unsettled member, and the summary counts them under unsettled. A walk that stops before settling adds the incomplete-walk reason to the targets and jumps it found.

With --call-targets-only, a backward liveness walk first works out what the targets and jumps read, narrowed by the area-need table under --narrow-areas. The forward walk then applies that result to every state it hands on.

The flags are documented in the field dataflow reference. Next: answering every walk once.