Explainer 14 of 15

Summarising a PERFORM

Read first: Fixpoints and worklists, Value sets and dynamic calls

The solution in one example

MAIN-PARA.
    MOVE 'PROGA' TO WS-A.
    PERFORM OUTER.
    MOVE WS-C TO WS-X.
    CALL WS-X.
    STOP RUN.
OUTER.
    PERFORM INNER.
    MOVE WS-B TO WS-C.
INNER.
    MOVE WS-A TO WS-B.

Values are shown with trailing spaces dropped (PROGA is 'PROGA␣␣␣'); U means uninitialised. Every field on this page is PIC X(8), laid out in declaration order: here WS-A is working-storage bytes [0,8), WS-B [8,16), WS-C [16,24); in section 4's program WS-D is [24,32); in the two-call program below, WS-V is [0,8) and WS-W [8,16).

Each performed paragraph becomes a function on just the fields it touches. Build them from the inside out:

paragraphtouchesfunctionbuilt how
INNERWS-A, WS-BfINNER(A, B) = (A, A)its one MOVE
OUTERWS-B, WS-C, plus INNER's WS-A, WS-BfOUTER(A, B, C) = (A, A, A)[C := B] ∘ fINNER: apply INNER, then OUTER's MOVE

The PERFORM OUTER in MAIN-PARA then applies fOUTER to (A = PROGA, B = U, C = U), gets (PROGA, PROGA, PROGA), and hands it to the statement after itself. WS-X is not in OUTER's fields, so it skips the call unchanged. MOVE WS-C TO WS-X makes it PROGA, and cobble resolves CALL WS-X to PROGA. Cobble's footprints for the two paragraphs are working-storage bytes [0,16) (WS-A, WS-B) and [0,24) (WS-A to WS-C).

The same functions keep two calls of one paragraph apart. P is performed twice, and a real run calls PROGA at CALL WS-W:

MAIN-PARA.
    MOVE 'PROGA' TO WS-V.        MAIN-PARA/0
    PERFORM P.                   MAIN-PARA/1
    CALL WS-W.                   MAIN-PARA/2
    MOVE 'PROGB' TO WS-V.        MAIN-PARA/3
    PERFORM P.                   MAIN-PARA/4
    STOP RUN.                    MAIN-PARA/5
P.
    MOVE WS-V TO WS-W.           P/0

P touches WS-V and WS-W, so it becomes fP(V, W) = (V, V). Each PERFORM applies it to its own state and hands the result only to the statement after itself:

goes infP givesgoes back to
MAIN-PARA/1V = PROGA, W = UV = PROGA, W = PROGAMAIN-PARA/2 CALL WS-W only
MAIN-PARA/4V = PROGB, W = PROGAV = PROGB, W = PROGBMAIN-PARA/5 STOP RUN only

So CALL WS-W sees only PROGA, as in a real run. An analysis that just follows the control-flow graph also lets the second call's result reach CALL WS-W, and says PROGA or PROGB. The rest of the page shows that failure, the rules at the boundary of a performed paragraph, the new walk, and how nested PERFORMs compose.

The method in notation

A state. A state \(\sigma\) is what the analysis knows at one point in the program: for each field \(x\), \(\sigma[x]\) is the set of values \(x\) may hold there. For a set of fields \(X\), \(\sigma[X]\) is the same, kept only for the fields in \(X\). \(U\) in a set means "may be uninitialised". (Cobble works per byte range rather than per field.)

A node. Each node \(n\) has two states: \(\mathit{in}(n)\), arriving at it, and \(\mathit{out}(n)\), leaving it. It reads the fields \(\mathit{reads}(n)\) and writes the fields \(\mathit{writes}(n)\). Let \(g_n\) be what \(n\)'s statement computes: it takes the state of the fields \(n\) reads and returns a state of the fields \(n\) writes, holding their new values. So \(g_n(\mathit{in}(n)[\mathit{reads}(n)])\) is a state, and \(g_n(\mathit{in}(n)[\mathit{reads}(n)])[x]\) is the new value set it gives field \(x\).

\[ \mathit{out}(n) = f_n(\mathit{in}(n)) \] \[ \mathit{out}(n)[x] = \begin{cases} \mathit{in}(n)[x] & x \notin \mathit{writes}(n) \\ g_n\big(\mathit{in}(n)[\mathit{reads}(n)]\big)[x] & x \in \mathit{writes}(n),\ \text{strong write} \\ \mathit{in}(n)[x] \,\cup\, g_n\big(\mathit{in}(n)[\mathit{reads}(n)]\big)[x] & x \in \mathit{writes}(n),\ \text{weak write} \end{cases} \]

For example, for \(n\) = MOVE WS-V TO WS-W: \(\mathit{reads}(n) = \{\texttt{WS-V}\}\), \(\mathit{writes}(n) = \{\texttt{WS-W}\}\), and \(\mathit{out}(n)[\texttt{WS-W}] = \mathit{in}(n)[\texttt{WS-V}]\). A write is weak when cobble cannot be sure it lands on \(x\): the target is a table element whose subscript is not a constant, or a reference modification whose start or length is not a constant, or the statement is one cobble has no rule for but whose node lists fields it writes. Then the old values are kept as well. A statement with no rule and no listed writes leaves every field as it was.

The join. A node with several incoming edges gets the field-by-field union of the states leaving the nodes those edges come from, its predecessors \(p_1, \dots, p_k\). Write \(\sqcup\) for that union:

\[ \mathit{in}(n) = \mathit{out}(p_1) \sqcup \dots \sqcup \mathit{out}(p_k), \qquad (\sigma_1 \sqcup \sigma_2)[x] = \sigma_1[x] \cup \sigma_2[x] \]

For example, \(\sigma_1[\texttt{WS-V}] = \{\text{PROGA}\}\) and \(\sigma_2[\texttt{WS-V}] = \{\text{PROGB}\}\) give \((\sigma_1 \sqcup \sigma_2)[\texttt{WS-V}] = \{\text{PROGA}, \text{PROGB}\}\). \(\bot\) is no state at all: no route has reached that point yet. \(\bot \sqcup \sigma = \sigma\), and every function on this page maps \(\bot\) to \(\bot\).

A performed range. A range \(Q\) is what one PERFORM runs: from \(Q\)'s entry node \(\mathrm{entry}_Q\) to \(Q\)'s end node \(\mathrm{end}_Q\). \(n \in Q\) means \(n\) is one of \(Q\)'s nodes: those a route from \(\mathrm{entry}_Q\) reaches without passing \(\mathrm{end}_Q\), along every edge but PERFORM and RETURN edges (so a GO TO out of \(Q\)'s paragraphs is followed), a PERFORM node stepping over its range to the statement after it unless that range is entered at \(\mathrm{entry}_Q\). Its footprint \(F(Q)\) is the set of fields that \(Q\)'s nodes read or write, together with the footprints of the ranges performed at \(Q\)'s nodes, other than ranges entered at \(\mathrm{entry}_Q\). The definition is recursive:

\[ F(Q) = \bigcup_{n \in Q} \big(\mathit{reads}(n) \cup \mathit{writes}(n)\big) \;\cup \bigcup_{\substack{Q_2 \text{ performed at some } n \in Q \\ \mathrm{entry}_{Q_2} \neq \mathrm{entry}_Q}} F(Q_2) \]

Cobble does not evaluate it by recursion. It starts every range's footprint at its own nodes' fields, then keeps adding the footprints of the ranges each one performs until no footprint grows. That gives the smallest sets satisfying the equation, and it stops even when PERFORMs form a cycle.

For a set of fields \(X\), let \(S(X)\) be the set of all states over \(X\): every way of giving each field in \(X\) a value set. \(Q\)'s transfer function \(f_Q\) takes the arriving state, kept to \(F(Q)\), to the state leaving \(\mathrm{end}_Q\), kept to \(F(Q)\) as well, or to \(\bot\) when no route reaches \(\mathrm{end}_Q\). Fields in \(F(Q)\) that \(Q\) only reads come out as they went in.

\[ f_Q : S(F(Q)) \to S(F(Q)) \cup \{\bot\} \]

For example, for \(P\) = MOVE WS-V TO WS-W, \(F(P) = \{\texttt{WS-V}, \texttt{WS-W}\}\) and

\[ \begin{aligned} &f_P\big(\texttt{WS-V} \mapsto \{\text{PROGA}\},\ \texttt{WS-W} \mapsto \{U\}\big) \\ &\quad = \big(\texttt{WS-V} \mapsto \{\text{PROGA}\},\ \texttt{WS-W} \mapsto \{\text{PROGA}\}\big) \end{aligned} \]

A PERFORM node \(p\) of range \(Q\) applies \(f_Q\) to the fields in \(F(Q)\) and keeps every other field as it arrived. Let \(\mathbb{F}\) be the set of all the program's fields, so \(\mathbb{F} \setminus F(Q)\) is the fields \(Q\) never touches, and write \(\sigma_1 \oplus \sigma_2\) for putting together two states over disjoint sets of fields:

\[ \mathit{out}(p) = f_Q\big(\mathit{in}(p)[F(Q)]\big) \,\oplus\, \mathit{in}(p)[\mathbb{F} \setminus F(Q)] \]

with \(\bot \oplus \sigma = \bot\). Where \(p\)'s state goes depends on whether any edge of the graph leads to \(\mathrm{end}_Q\):

\[ p \text{ sends } \begin{cases} \mathit{out}(p) \text{ to } \mathit{resume}(p) \text{ only} & \text{if an edge leads to } \mathrm{end}_Q \\ \mathit{in}(p) \text{ to the statement after } p \text{ only} & \text{otherwise} \end{cases} \]

\(\mathit{resume}(p)\) is the statement right after that PERFORM; sending \(\bot\) sends nothing. In the second case the graph says \(Q\) never returns, so the statement after \(p\) would be dead. Cobble keeps \(p\)'s edge to it anyway, because the graph can be wrong (an ALTERed GO TO, control flow the parser dropped), and passes on the state from before the PERFORM, ignoring \(Q\)'s writes.

Composition. For a range whose nodes run in a straight line \(n_1, \dots, n_k\), its function is theirs applied in order, with a nested PERFORM node's rule standing in for the inner range. With branches or loops, \(f_Q(\sigma)\) is the state leaving \(\mathrm{end}_Q\) once a walk of \(Q\) from \(\sigma\) stops changing, or \(\bot\) if the walk never reaches \(\mathrm{end}_Q\).

\[ f_Q = f_{n_k} \circ \dots \circ f_{n_1} \]

For example, with OUTER = PERFORM INNER, then MOVE WS-B TO WS-C, and INNER = MOVE WS-A TO WS-B:

\[ f_{\mathrm{INNER}}(a, b) = (a, a) \quad \text{over } (\texttt{WS-A}, \texttt{WS-B}) \] \[ f_{\mathrm{OUTER}} = f_{\texttt{MOVE WS-B TO WS-C}} \circ f_{\mathrm{INNER}} \] \[ f_{\mathrm{OUTER}}(a, b, c) = (a, a, a) \quad \text{over } (\texttt{WS-A}, \texttt{WS-B}, \texttt{WS-C}) \]

The fixpoint, with summaries. \(\mathit{walk}(Q, \sigma)\) walks range \(Q\) from input \(\sigma\) and returns \(\mathit{out}(\mathrm{end}_Q)\), the state leaving \(Q\)'s end node, or \(\bot\) if the walk never reached it. \(\mathit{results}\) is a table of finished walks, keyed by (range, input). The worklist \(L\) holds the nodes whose arriving state has grown and must be visited again. \(\mathit{succ}_Q(n)\) is \(n\)'s successors along every edge but PERFORM and RETURN edges, and none if \(n\) is \(\mathrm{end}_Q\); a PERFORM node with a \(\mathit{resume}\) has no other such edge. \(\sigma' \not\sqsubseteq \mathit{in}(m)\) means \(\mathit{in}(m) = \bot \neq \sigma'\), or \(\sigma'\) has, for some field, a value \(\mathit{in}(m)\) lacks. \(\mathrm{end}_Q\) is visited like any other node; it differs only in having no successors, so its \(\mathit{out}\) is returned rather than passed on. The whole analysis is \(\mathit{walk}(T, \sigma_{\text{start}})\), from the program's starting state, where \(T\) is the whole program taken as a range with no end node: \(\mathrm{entry}_T\) is the first paragraph's start node, and \(\mathit{succ}_T\) cuts no node's successors.

\[ \begin{array}{l} \mathit{walk}(Q, \sigma): \\ \quad \mathit{in}(\mathrm{entry}_Q) := \sigma;\ \ \mathit{in}(n) := \bot \text{ for every other } n;\ \ \mathit{out}(n) := \bot \text{ for every } n \\ \quad L := \{\mathrm{entry}_Q\} \\ \quad \textbf{while } L \neq \varnothing: \\ \quad\quad \text{take a node } n \text{ out of } L \\ \quad\quad \textbf{if } n \text{ is a PERFORM of } Q_2: \\ \quad\quad\quad \mathit{input} := \mathit{in}(n)[F(Q_2)] \\ \quad\quad\quad \textbf{if } (Q_2, \mathit{input}) \notin \mathit{results}:\ \ \mathit{results}[Q_2, \mathit{input}] := \mathit{walk}(Q_2, \mathit{input}) \\ \quad\quad\quad \mathit{out}(n) := \mathit{results}[Q_2, \mathit{input}] \oplus \mathit{in}(n)[\mathbb{F} \setminus F(Q_2)] \\ \quad\quad\quad \textbf{if } \mathit{resume}(n) \text{ exists}:\ \ \mathit{send}(\mathit{out}(n), \mathit{resume}(n)) \\ \quad\quad\quad \textbf{else}:\ \ \mathit{send}(\mathit{in}(n), m) \text{ for each } m \in \mathit{succ}_Q(n) \\ \quad\quad \textbf{else}: \\ \quad\quad\quad \mathit{out}(n) := f_n(\mathit{in}(n));\ \ \mathit{send}(\mathit{out}(n), m) \text{ for each } m \in \mathit{succ}_Q(n) \\ \quad \textbf{return } \mathit{out}(\mathrm{end}_Q) \\[6pt] \mathit{send}(\sigma', m): \\ \quad \textbf{if } \sigma' \not\sqsubseteq \mathit{in}(m):\ \ \mathit{in}(m) := \mathit{in}(m) \sqcup \sigma';\ \ L := L \cup \{m\} \end{array} \]

The PERFORM stack. The recursion of \(\mathit{walk}\) is a stack of PERFORMs. Reaching a PERFORM node \(n\) pushes a new \(\mathit{walk}\) for its range; when that walk returns, it is popped and the outer walk is still at \(n\), so it reads \(\mathit{resume}(n)\) from the right node. That is how each call's result returns to its own call site, which a walk following RETURN edges cannot tell apart. In section 4's example, where MAIN-PARA runs PERFORM P1 THRU P2 and paragraph P1 runs PERFORM SET-B, the stack is at most three deep: \(T\), then the range from P1's start to P2's end, then SET-B.

Left out for brevity: cobble also keeps the set of entry nodes of the ranges on the stack. A PERFORM of a range whose entry is in that set ends that route (COBOL leaves recursive PERFORM undefined), so PERFORM P1 inside a walk of P1 THRU P2 is refused. The table's key also holds the part of that set the range's walk can come to a PERFORM of. A PERFORM statement reached again, with the same set and an input that contains its last one, continues its last walk instead of starting over.

1. The problem: a walk that follows the graph

MAIN-PARA.
    MOVE 'PROGA' TO WS-V.  MAIN-PARA/0
    PERFORM P.             MAIN-PARA/1
    CALL WS-W.             MAIN-PARA/2
    MOVE 'PROGB' TO WS-V.  MAIN-PARA/3
    PERFORM P.             MAIN-PARA/4
    STOP RUN.              MAIN-PARA/5
P.
    MOVE WS-V TO WS-W.     P/0

A real run calls PROGA at MAIN-PARA/2. Cobble's control-flow graph has these edges into and out of P, where P#label and P#end are nodes marking the paragraph's start and end:

flowchart TD
    M1["MAIN-PARA/1 PERFORM P"] -->|PERFORM| PL["P#label"]
    M4["MAIN-PARA/4 PERFORM P"] -->|PERFORM| PL
    PL --> P0["P/0 MOVE WS-V TO WS-W"]
    P0 --> PE["P#end"]
    PE -->|RETURN| M2["MAIN-PARA/2 CALL WS-W"]
    PE -->|RETURN| M5["MAIN-PARA/5 STOP RUN"]

Below is the walk cobble's fixpoint made before this change, printed step by step from that version of the code run on this program. Each row is one visit: the node taken off the worklist, the value sets arriving at it, and the nodes whose arriving state it changed (they are queued). The walk is not one run: it takes nodes off the worklist until none is left, so the STOP RUN at MAIN-PARA/5, which has no successors, ends only that line of the walk.

visitnodeWS-VWS-Wchanges the state arriving at
1MAIN-PARA#label MAIN-PARA. (start)UUMAIN-PARA/0 MOVE 'PROGA' TO WS-V
2MAIN-PARA/0 MOVE 'PROGA' TO WS-VUUMAIN-PARA/1 PERFORM P
3MAIN-PARA/1 PERFORM PPROGAUP#label P. (start)
4P#label P. (start)PROGAUP/0 MOVE WS-V TO WS-W
5P/0 MOVE WS-V TO WS-WPROGAUP#end end of P
6P#end end of PPROGAPROGAMAIN-PARA/2 CALL WS-W, MAIN-PARA/5 STOP RUN
7MAIN-PARA/5 STOP RUNPROGAPROGAnothing changed
8MAIN-PARA/2 CALL WS-WPROGAPROGAMAIN-PARA/3 MOVE 'PROGB' TO WS-V
9MAIN-PARA/3 MOVE 'PROGB' TO WS-VPROGAPROGAMAIN-PARA/4 PERFORM P
10MAIN-PARA/4 PERFORM PPROGBPROGAP#label P. (start)
11P#label P. (start)PROGA, PROGBPROGA, UP/0 MOVE WS-V TO WS-W
12P/0 MOVE WS-V TO WS-WPROGA, PROGBPROGA, UP#end end of P
13P#end end of PPROGA, PROGBPROGA, PROGBMAIN-PARA/2 CALL WS-W, MAIN-PARA/5 STOP RUN
14MAIN-PARA/5 STOP RUNPROGA, PROGBPROGA, PROGBnothing changed
15MAIN-PARA/2 CALL WS-WPROGA, PROGBPROGA, PROGBMAIN-PARA/3 MOVE 'PROGB' TO WS-V
16MAIN-PARA/3 MOVE 'PROGB' TO WS-VPROGA, PROGBPROGA, PROGBMAIN-PARA/4 PERFORM P
17MAIN-PARA/4 PERFORM PPROGBPROGA, PROGBP#label P. (start)
18P#label P. (start)PROGA, PROGBPROGA, PROGB, UP/0 MOVE WS-V TO WS-W
19P/0 MOVE WS-V TO WS-WPROGA, PROGBPROGA, PROGB, Unothing changed
  • Visit 6: P#end sends the first call's state down both RETURN edges, so MAIN-PARA/2 and MAIN-PARA/5 are both queued. Visit 7 takes MAIN-PARA/5, the STOP RUN; visit 8 takes MAIN-PARA/2.
  • Visit 11: the state arriving at P#label is the join of both calls': WS-V is PROGA, PROGB.
  • Visit 13: P#end sends that joined state down both RETURN edges again, so the second call's PROGB reaches the first call's resume point.
  • Visit 15: the state arriving at CALL WS-W has WS-W = PROGA, PROGB. That is what cobble reported before this change; it now reports PROGA.

Both mistakes happen at P: its start joins the two calls' states, and its end sends the result to both resume points. The walk also goes round MAIN-PARA/2 → /3 → /4 → P → MAIN-PARA/2, a cycle the program does not have.

2. Cutting out a range: the contract

The fix is to stop treating a performed paragraph as part of the graph the walk wanders through, and treat it as a unit with a boundary. Each PERFORM names a range: where it enters, and the end node it returns from.

statementrange (entry, end)
PERFORM P(P#label, P#end)
PERFORM P1 THRU P2(P1#label, P2#end)

At the boundary of a range R, the contract is:

What this implies: R becomes a function fR from a state over its footprint to a state over its footprint, its transfer function. A PERFORM of R is then an ordinary node whose transfer is "cut, apply fR, put the rest back". For P, f maps (WS-V = v, WS-W = w) to (WS-V = v, WS-W = v).

3. The walk now

The same program under the contract, printed step by step from cobble's current fixpoint. Numbered rows are visits of the top-level walk, which starts at MAIN-PARA. Under a PERFORM's visit are the rows of that call: the cut, whether the table already had a result, the separate walk of the range (states arriving at each of its nodes), and what is handed back and where. P touches both fields, so nothing is held out here; the opening example and section 4 show fields held out.

MAIN-PARA.
    MOVE 'PROGA' TO WS-V.  MAIN-PARA/0
    PERFORM P.             MAIN-PARA/1
    CALL WS-W.             MAIN-PARA/2
    MOVE 'PROGB' TO WS-V.  MAIN-PARA/3
    PERFORM P.             MAIN-PARA/4
    STOP RUN.              MAIN-PARA/5
P.
    MOVE WS-V TO WS-W.     P/0
visitnode / actionWS-VWS-W
1MAIN-PARA#label MAIN-PARA. (start)UU
2MAIN-PARA/0 MOVE 'PROGA' TO WS-VUU
3MAIN-PARA/1 PERFORM PPROGAU
PERFORM at MAIN-PARA/1: range P … P#end
  cut: passed inPROGAU
  cut: held out··
  tablemiss, walked
  walk: arriving at P#label P. (start)PROGAU
  walk: arriving at P/0 MOVE WS-V TO WS-WPROGAU
  walk: arriving at P#end end of PPROGAPROGA
  hand back, sent to MAIN-PARA/2 CALL WS-WPROGAPROGA
4MAIN-PARA/2 CALL WS-WPROGAPROGA
5MAIN-PARA/3 MOVE 'PROGB' TO WS-VPROGAPROGA
6MAIN-PARA/4 PERFORM PPROGBPROGA
PERFORM at MAIN-PARA/4: range P … P#end
  cut: passed inPROGBPROGA
  cut: held out··
  tablemiss, walked
  walk: arriving at P#label P. (start)PROGBPROGA
  walk: arriving at P/0 MOVE WS-V TO WS-WPROGBPROGA
  walk: arriving at P#end end of PPROGBPROGB
  hand back, sent to MAIN-PARA/5 STOP RUNPROGBPROGB
7MAIN-PARA/5 STOP RUNPROGBPROGB

Compare with section 1. Each walk of P starts from one call's state, so nothing is joined at P#label. Each hand-back goes to one node: MAIN-PARA/2 for the first call, MAIN-PARA/5 for the second. The top-level walk visits each statement once, with no cycle, and WS-W arrives at CALL WS-W (visit 4) as PROGA only.

4. Composing ranges

When a range performs another, its function is built from the inner one. Which function a PERFORM applies depends on its range, not just on the paragraph it names:

MAIN-PARA.
    MOVE 'PROGA' TO WS-A.  MAIN-PARA/0
    PERFORM P1.            MAIN-PARA/1
    MOVE WS-B TO WS-X.     MAIN-PARA/2
    MOVE 'PROGB' TO WS-A.  MAIN-PARA/3
    PERFORM P1 THRU P2.    MAIN-PARA/4
    MOVE WS-C TO WS-Y.     MAIN-PARA/5
    CALL WS-X.             MAIN-PARA/6
    CALL WS-Y.             MAIN-PARA/7
    STOP RUN.              MAIN-PARA/8
P1.
    PERFORM SET-B.         P1/0
    MOVE 'DONE' TO WS-D.   P1/1
P2.
    MOVE WS-B TO WS-C.     P2/0
SET-B.
    MOVE WS-A TO WS-B.     SET-B/0

Cobble's footprints for the three ranges, printed from its own computation:

rangeown statements touchplus ranges it performsfootprint (bytes)function
(SET-B, SET-B#end end of SET-B)WS-A, WS-Bnone[0,16)(a, b) ↦ (a, a)
(P1, P1#end end of P1)WS-DSET-B: WS-A, WS-B[0,16) ∪ [24,32)(a, b, d) ↦ (a, a, DONE)
(P1, P2#end end of P2)WS-D, WS-B, WS-CSET-B: WS-A, WS-B[0,32)(a, b, c, d) ↦ (a, a, a, DONE)

The two ranges entered at P1 are different functions. PERFORM P1 stops at P1#end. PERFORM P1 THRU P2 falls through from P1#end into P2, so it also copies WS-B into WS-C. Each is a composition along its walk, with the inner range's function standing in at P1/0:

f(P1, P1#end)  = [P1/1: D := 'DONE'] ∘ fSET-B
f(P1, P2#end)  = [P2/0: C := B] ∘ [P1/1: D := 'DONE'] ∘ fSET-B

How the calls play out, printed from cobble's current fixpoint the same way. The nested PERFORM SET-B at P1/0 appears inside each walk of a range entered at P1, at the point that walk reaches P1/0:

visitnode / actionWS-AWS-BWS-CWS-DWS-XWS-Y
1MAIN-PARA#label MAIN-PARA. (start)UUUUUU
2MAIN-PARA/0 MOVE 'PROGA' TO WS-AUUUUUU
3MAIN-PARA/1 PERFORM P1PROGAUUUUU
PERFORM at MAIN-PARA/1: range P1 … P1#end
  cut: passed inPROGAU·U··
  cut: held out··U·UU
  tablemiss, walked
  walk: arriving at P1#label P1. (start)PROGAU·U··
  walk: arriving at P1/0 PERFORM SET-BPROGAU·U··
  PERFORM at P1/0: range SET-B … SET-B#end
    cut: passed inPROGAU····
    cut: held out···U··
    tablemiss, walked
    walk: arriving at SET-B#label SET-B. (start)PROGAU····
    walk: arriving at SET-B/0 MOVE WS-A TO WS-BPROGAU····
    walk: arriving at SET-B#end end of SET-BPROGAPROGA····
    hand back, sent to P1/1 MOVE 'DONE' TO WS-DPROGAPROGA·U··
  walk: arriving at P1/1 MOVE 'DONE' TO WS-DPROGAPROGA·U··
  walk: arriving at P1#end end of P1PROGAPROGA·DONE··
  hand back, sent to MAIN-PARA/2 MOVE WS-B TO WS-XPROGAPROGAUDONEUU
4MAIN-PARA/2 MOVE WS-B TO WS-XPROGAPROGAUDONEUU
5MAIN-PARA/3 MOVE 'PROGB' TO WS-APROGAPROGAUDONEPROGAU
6MAIN-PARA/4 PERFORM P1 THRU P2PROGBPROGAUDONEPROGAU
PERFORM at MAIN-PARA/4: range P1 … P2#end
  cut: passed inPROGBPROGAUDONE··
  cut: held out····PROGAU
  tablemiss, walked
  walk: arriving at P1#label P1. (start)PROGBPROGAUDONE··
  walk: arriving at P1/0 PERFORM SET-BPROGBPROGAUDONE··
  PERFORM at P1/0: range SET-B … SET-B#end
    cut: passed inPROGBPROGA····
    cut: held out··UDONE··
    tablemiss, walked
    walk: arriving at SET-B#label SET-B. (start)PROGBPROGA····
    walk: arriving at SET-B/0 MOVE WS-A TO WS-BPROGBPROGA····
    walk: arriving at SET-B#end end of SET-BPROGBPROGB····
    hand back, sent to P1/1 MOVE 'DONE' TO WS-DPROGBPROGBUDONE··
  walk: arriving at P1/1 MOVE 'DONE' TO WS-DPROGBPROGBUDONE··
  walk: arriving at P1#end end of P1PROGBPROGBUDONE··
  walk: arriving at P2#label P2. (start)PROGBPROGBUDONE··
  walk: arriving at P2/0 MOVE WS-B TO WS-CPROGBPROGBUDONE··
  walk: arriving at P2#end end of P2PROGBPROGBPROGBDONE··
  hand back, sent to MAIN-PARA/5 MOVE WS-C TO WS-YPROGBPROGBPROGBDONEPROGAU
7MAIN-PARA/5 MOVE WS-C TO WS-YPROGBPROGBPROGBDONEPROGAU
8MAIN-PARA/6 CALL WS-XPROGBPROGBPROGBDONEPROGAPROGB
9MAIN-PARA/7 CALL WS-YPROGBPROGBPROGBDONEPROGAPROGB
10MAIN-PARA/8 STOP RUNPROGBPROGBPROGBDONEPROGAPROGB

Inside the walk of (P1, P1#end), the nested cut holds out only WS-D, the one field of that range SET-B does not touch. Inside the walk of (P1, P2#end) it holds out WS-C and WS-D. Either way SET-B hands back to P1/1, because that is P1/0's resume node. Both walks of SET-B were table misses, because they were passed different values. Counted in cobble's fixpoint on this program: 2 walks of a range entered at P1 (one per range) and 2 walks of SET-B. It resolves CALL WS-X to PROGA and CALL WS-Y to PROGB.

5. Why composing stays correct

The claim: the state a PERFORM hands back is the state you would get by pasting the range's statements in place of the PERFORM and walking them there. Pasting is correct by construction, since each pasted copy can only return to where it was pasted. The argument, by how deeply ranges nest:

Checked on both examples: the programs with every PERFORM replaced by a pasted copy of its range give cobble the same answers.

programanswer
two-call program (sections 1–3), with PERFORMsCALL WS-W: PROGA
two-call program, pastedCALL WS-W: PROGA
nested example (section 4), with PERFORMsCALL WS-X: PROGA, CALL WS-Y: PROGB
nested example, pastedCALL WS-X: PROGA, CALL WS-Y: PROGB

The argument needs four things to hold, and breaks without any one of them:

In cobble

The fixpoint stage (cobble-dataflow-fixpoint) handles every PERFORM this way, with no option to turn it off. --sweeps N, which bounds the walk to N passes over the program, bounds every range walk the same way, and marks the program unsettled if any walk stopped with work left. Cobble computes every range's footprint once per program, before walking. A PERFORM of a paragraph the program does not declare has no range and no PERFORM edge: it keeps its edge to the next statement and passes its state on unchanged.