Explainer 8 of 11

Rounds across programs, and walking only what changed

How area need settles across a whole corpus: walk every program once, then walk again only the programs whose inputs moved, until none did.

Where this shows up in reverse engineering

The goal on a large CICS corpus (about 3,800 programs) was to resolve dynamic call targets: CALL WS-PGM, EXEC CICS XCTL PROGRAM(WS-NEXT-PGM). To do that cheaply, the fixpoint should track only the handed-over bytes some target can depend on. Area need computes those bytes per program. On CardDemo, the entry need summed over every program fell from 4,394 bytes to 92, and the fixpoint narrowed to it gave identical targets, links and graph.

The problem

Each program (a member) has two rows in the area table:

A member's walk is ordinary backward liveness over its own statements (see fixpoints). But the demand that walk starts from comes from the table. At a call that hands over an area, the member must supply what the callee reads on entry: the callee's entry row. At its own GOBACK or EXEC CICS RETURN, it must supply what its callers read back: its own back row. At a dynamic call nothing resolved, it must supply what any member might read: the union of every member's entry row.

So the walk's output depends on rows that other walks produce. A member's entry row feeds its callers. Its read-backs feed its callees' back rows. Per-program liveness, run once, sees only the rows as they stood when it ran. It cannot settle a dependency that runs through other programs.

The intuition

Start every member at "needs nothing". Walk every member against that table. Collect what each walk reports and build a new table. Walk again against the new table. Rows only grow, and they are bounded by the size of the areas, so eventually a round changes nothing. That table is the least one that holds.

Walking every member every round is correct but wasteful. A walk reads exactly three things from the table:

  1. the entry rows of the programs its call sites reach;
  2. its own back row;
  3. if it has a call site that hands over an area and that nothing resolved, the union of every member's entry row (the dynamic need).

Everything else it reads is its own program, which never changes. So if none of those three things changed since a member was last walked, walking it again gives the same report. Keep its last report and skip it. That leaves three reasons to walk a member again:

The first reason needs "who calls this program", but each member only knows what it calls. So the callee map is inverted once, before the first round: the inversion turns "member → programs its sites reach" into "program → members whose sites reach it". The sites counted are those naming a program, plus dynamic sites resolved to one or more programs (in their own program, see local site resolution, or by an earlier combine).

This is the worklist idea one level up. There, the items are statements, and a statement whose notes changed queues its neighbours. Here, the items are members, and a row that changed queues the members that read it. One difference: within a program the worklist takes one statement at a time and its result is visible at once. Here a whole round of members walks against the same table, built at the end of the previous round. That is what lets the walks of one round run in parallel.

The example

Four programs share one 16-byte commarea layout. PROGA LINKs to PROGB, which LINKs to PROGC. After PROGB returns, PROGA XCTLs to whatever program name came back. PROGD reads a program name from a queue and XCTLs there, handing its commarea on.

      *> Layout shared by PROGB, PROGC, PROGD (PROGA uses WS-AREA, same shape)
       01  DFHCOMMAREA.
           05  CA-NEXT-PGM          PIC X(8).
           05  CA-CODE              PIC X(2).
           05  CA-ACCT               PIC X(6).

       PROGRAM-ID. PROGA.
       WORKING-STORAGE SECTION.
       01  WS-AREA.
           05  WS-NEXT-PGM          PIC X(8).
           05  WS-CODE              PIC X(2).
           05  WS-ACCT               PIC X(6).
       PROCEDURE DIVISION.
           EXEC CICS LINK PROGRAM('PROGB') COMMAREA(WS-AREA) END-EXEC.
           EXEC CICS XCTL PROGRAM(WS-NEXT-PGM) END-EXEC.

       PROGRAM-ID. PROGB.
       PROCEDURE DIVISION.
           EXEC CICS LINK PROGRAM('PROGC') COMMAREA(DFHCOMMAREA) END-EXEC.
           EXEC CICS RETURN END-EXEC.

       PROGRAM-ID. PROGC.
       PROCEDURE DIVISION.
           IF EIBAID = DFHPF3
               MOVE 'PROGE' TO CA-NEXT-PGM
           END-IF.
           EXEC CICS RETURN END-EXEC.

       PROGRAM-ID. PROGD.
       WORKING-STORAGE SECTION.
       01  WS-NEXT                  PIC X(8).
       PROCEDURE DIVISION.
           EXEC CICS READQ TS QUEUE('NEXTQ') INTO(WS-NEXT) END-EXEC.
           EXEC CICS XCTL PROGRAM(WS-NEXT) COMMAREA(DFHCOMMAREA) END-EXEC.

What the walks need to know about these programs:

The callee map and its inversion:

membercallees (named or resolved)callers()
PROGA{PROGB}—
PROGB{PROGC}{PROGA}
PROGC{}{PROGB}
PROGD{} (one unresolved site)—

Members with an unresolved site that hands over an area: {PROGD}. Rows below are commarea byte ranges, written half-open: [0,8) is bytes 0 to 7, the eight bytes of CA-NEXT-PGM.

Step by step

Each step is one round. It shows which members were walked, the table after the round (amber: changed in this round), and the members dirtied for the next round with the reason.

Rounds until the table settles (5 rounds, 9 walks)
  1. Every member starts needing nothing. The dynamic need is the union of the entry rows, so it is empty too. Every member is dirty: the first round walks them all.

    memberentryback
    PROGA{}{}
    PROGB{}{}
    PROGC{}{}
    PROGD{}{}
    dynamic{}

    To walk

    PROGAPROGBPROGCPROGD
  2. Walk all four against the empty table. Only PROGA finds anything: its XCTL reads [0,8) after the LINK to PROGB, so it reports reading [0,8) back from PROGB. That becomes PROGB's back row. No entry row changed, and the dynamic need did not change.

    memberentryback
    PROGA{}{}
    PROGB{}[0,8)
    PROGC{}{}
    PROGD{}{}
    dynamic{}

    To walk next — PROGB: its own back changed

    PROGB
  3. Walk PROGB. Its RETURN now demands [0,8), which is live back to the LINK. So PROGB reads [0,8) back from PROGC. The LINK writes the area, so nothing reaches PROGB's entry: its entry row stays empty. PROGA, PROGC and PROGD keep their round-1 reports.

    memberentryback
    PROGA{}{}
    PROGB{}[0,8)
    PROGC{}[0,8)
    PROGD{}{}
    dynamic{}

    To walk next — PROGC: its own back changed

    PROGC
  4. Walk PROGC. Its RETURN demands [0,8). The MOVE covers those bytes on one path only, so they are live on entry. PROGC's entry row changed: its callers, {PROGB}, are dirty. The union of entry rows grew, so the dynamic need changed: every member with an unresolved site, {PROGD}, is dirty.

    memberentryback
    PROGA{}{}
    PROGB{}[0,8)
    PROGC[0,8)[0,8)
    PROGD{}{}
    dynamic[0,8)

    To walk next — PROGB: caller of PROGC, whose entry changed; PROGD: dynamic need changed

    PROGBPROGD
  5. Walk PROGB and PROGD, both against the round-3 table. PROGB's LINK must now supply PROGC's entry need, so [0,8) is live on entry. PROGD's unresolved XCTL must supply the dynamic need, so it too needs [0,8) on entry. PROGB's entry changed: its caller PROGA is dirty. PROGD's entry changed, but nothing calls it. The union is still [0,8), so the dynamic need did not change. No back row changed.

    memberentryback
    PROGA{}{}
    PROGB[0,8)[0,8)
    PROGC[0,8)[0,8)
    PROGD[0,8){}
    dynamic[0,8)

    To walk next — PROGA: caller of PROGB, whose entry changed

    PROGA
  6. Walk PROGA. Its LINK now demands [0,8) of WS-AREA, but PROGA receives no area, so it has no entry row to grow. It still reads [0,8) back from PROGB. Nothing changed, so nothing is dirty. The rounds stop.

    memberentryback
    PROGA{}{}
    PROGB[0,8)[0,8)
    PROGC[0,8)[0,8)
    PROGD[0,8){}
    dynamic[0,8)

    To walk next

    none — settled

Walking every member every round produces the same five tables, round for round, because a skipped member would have reported exactly what it reported last time. It costs 5 × 4 = 20 walks. The dirty sets cost 4 + 1 + 1 + 2 + 1 = 9.

Notice the direction each fact travels. A back row moves down the chain (PROGA → PROGB → PROGC), one hop per round. An entry row moves up (PROGC → PROGB → PROGA), one hop per round. The dynamic need moves sideways to PROGD, which no call chain connects to the others.

With --max-rounds 3, the run stops after round 3 and warns that it stopped after 3 rounds with 2 members still to walk. The table it writes has PROGB and PROGD needing nothing on entry. Those bytes arrive only through the longer chain PROGA → PROGB → PROGC and back, which three rounds did not finish carrying.

The rule

T₀ = every member needs nothing; dirty₀ = every member
round k: walk each member in dirty₍k−1₎ against T₍k−1₎; keep every other member's last report
T_k = resolve(all reports): entry = own report; back = what callers read back
      (plus what unresolved calls read back); dynamic = ⋃ entry
dirty_k = callers(members whose entry changed)
        ∪ members whose back changed
        ∪ (members with an unresolved site, if dynamic changed)
stop when dirty_k is empty, or when k = --max-rounds (N > 0), warning |dirty_k|

Why this reaches the same rows as walking everything: a walk reads only its program, which is fixed, and the three things named above. A member is left out of a round only when none of those changed since its last walk. So its kept report is the one a fresh walk would produce, and each T_k equals the table the walk-everything run builds in round k. It stops because rows only grow — more demand in a liveness walk never means fewer live bytes — and every row is bounded by the bytes of the areas.

What goes wrong

In cobble

The command is cobble-dataflow-area-need. Before the first round it inverts the callee map once. After each round it compares the table before and after and finds the three kinds of dirty member, and the next round walks only those. It stops when that set is empty, or after --max-rounds rounds with a warning. A walk reads, for each call, the callee's entry row, the dynamic need for an unresolved site, or every byte for an unknown callee; and for itself, its own back row.

The members are shared out among worker processes (--max-workers). Each worker holds its share of the members for the whole run; only the table and the reports cross between processes. One progress count, under a lock, is advanced by every worker as it finishes a load or a walk, so the log numbers members in the order they finish. It is never reset: each round subtracts the walks made before it to print [i/n] for that round.

The walk inside each member is the backward liveness worklist of fixpoints. What the rows mean, and where the demand comes from, is in area need.