Explainer 10 of 11

Answering every walk once: strongly connected components and a memo

Thousands of jumps each ask what their target bytes can hold, and their walks back through the programs keep reaching the same places. Answer each place once, and let every walk that reaches it stop there.

Where this shows up in reverse engineering

The last stage of cobble's field dataflow, the combine, resolves dynamic call targets across programs: CALL WS-PGM, EXEC CICS XCTL PROGRAM(WS-NEXT-PGM). When the name was put into the field in another program and handed over in a commarea, the answer is only found by walking back through the program that sent it, and the one that sent it there.

The problem

A place is a program, a node in it (a statement), and a byte range. The combine's question for a jump is: what values can the jump's target bytes hold? It answers by walking back from the jump's place. Each step looks at one place, collects any literal values written there, and lists the places the bytes were copied from: a MOVE inside the program, or, at the top of a program's linkage section, the caller that handed the commarea in.

The walk's answer is everything found at every place it reaches, joined. Done once per jump, the cost is the number of jumps times the number of places each one reaches. When most jumps reach the same few hundred places, almost all of that work is repeated.

The intuition

A place's answer does not depend on who is asking. It is the join of what the place finds itself and the answers of every place it leads to. So work it out once, keep it, and when another walk arrives at an answered place, take the answer and go no further.

Cycles get in the way of "work out the successors first". If A leads to C, C to B, and B back to A, none of them can be finished before the others. But they do not need to be: every place on a cycle reaches every other, so they all reach exactly the same set of places and all have the same answer. A maximal set of places that all reach each other is a strongly connected component. Solve the component as one unit: its answer is the join of what each member finds itself, plus the answers of the places the component leads out to.

Tarjan's algorithm finds the components during one depth-first walk, and it finishes each component only after every component it leads to. That is exactly the order the answers need. It numbers places in the order it first visits them (the index), and keeps for each the lowest index it can get back to through places still in play (the low value). Visited places go on a stack. When the walk backs out of a place whose low equals its own index, nothing below it reaches anything older, so that place and everything above it on the stack form one component. Pop them, give them their shared answer, and move on.

The textbook version is recursive, one call per place on the current path. Cobble keeps the path in explicit lists instead: the combine had already crashed once on Python's recursion limit when it recursed once per program, and a walk across a corpus can be millions of places deep.

Two smaller changes make each step cheaper. A step that reaches the top of a program asks which links lead into it; it used to scan every link the round knew, and a profiler showed every sample inside that scan. The links are now indexed by what each step looks them up by. And a place is told apart by its bytes alone: the same bytes reached under a group name, an elementary name or a REDEFINES name are one place, since the walk never reads the name.

The example

Four programs hand an 8-byte commarea round. PROGA transfers to PROGB, PROGB to PROGC, PROGC back to PROGA: a 3-program ring. PROGD enters the ring from outside, with 'PROGE' in the first 8 bytes. PROGB and PROGC each have a dynamic XCTL to whatever name the commarea carries. Those two dynamic jumps are what the combine has to answer.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGA.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-AREA.
           05  WS-NEXT-PGM      PIC X(8).
       LINKAGE SECTION.
       01  DFHCOMMAREA.
           05  CA-NEXT-PGM      PIC X(8).
       PROCEDURE DIVISION.
       MAIN-PARA.
           MOVE DFHCOMMAREA TO WS-AREA.
           EXEC CICS XCTL PROGRAM('PROGB') COMMAREA(WS-AREA) END-EXEC.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGB.
      *    Same data division as PROGA.
       PROCEDURE DIVISION.
       MAIN-PARA.
           MOVE DFHCOMMAREA TO WS-AREA.
           IF CA-NEXT-PGM NOT = SPACES
               EXEC CICS XCTL PROGRAM(CA-NEXT-PGM) END-EXEC
           END-IF.
           EXEC CICS XCTL PROGRAM('PROGC') COMMAREA(WS-AREA) END-EXEC.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGC.
      *    Same data division as PROGA.
       PROCEDURE DIVISION.
       MAIN-PARA.
           MOVE DFHCOMMAREA TO WS-AREA.
           IF CA-NEXT-PGM NOT = SPACES
               EXEC CICS XCTL PROGRAM(CA-NEXT-PGM) END-EXEC
           END-IF.
           EXEC CICS XCTL PROGRAM('PROGA') COMMAREA(WS-AREA) END-EXEC.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGD.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-AREA.
           05  WS-NEXT-PGM      PIC X(8).
       PROCEDURE DIVISION.
       MAIN-PARA.
           MOVE 'PROGE' TO WS-NEXT-PGM.
           EXEC CICS XCTL PROGRAM('PROGA') COMMAREA(WS-AREA) END-EXEC.

The literal transfers resolve on their own, so by the round shown here the combine knows four links: A→B, B→C, C→A and D→A. Six places matter. Each dynamic jump starts at its own node, on CA-NEXT-PGM (linkage bytes 0 to 8). Each literal transfer is a place on WS-AREA (working-storage bytes 0 to 8) at that XCTL.

placeprogram, node, bytesfinds itselfleads to
B-jumpPROGB, MAIN-PARA/1.then.0, linkage[0:8]nothingA-xctl
C-jumpPROGC, MAIN-PARA/1.then.0, linkage[0:8]nothingB-xctl
A-xctlPROGA, MAIN-PARA/1, working_storage[0:8]nothingC-xctl, D-xctl
B-xctlPROGB, MAIN-PARA/2, working_storage[0:8]nothingA-xctl
C-xctlPROGC, MAIN-PARA/2, working_storage[0:8]nothingB-xctl
D-xctlPROGD, MAIN-PARA/1, working_storage[0:8]{PROGE}none

Read a row like this: the bytes at B-xctl were copied from DFHCOMMAREA by the MOVE, and PROGB's commarea is handed in by PROGA's transfer, so B-xctl leads to A-xctl. The ring of places A-xctl → C-xctl → B-xctl → A-xctl runs the opposite way to the ring of transfers, because the walk goes backwards. The jump at PROGB reads CA-NEXT-PGM and the link from PROGA hands over DFHCOMMAREA: two names, the same bytes, so one place.

Step by step

The combine answers programs in name order, so PROGB's jump goes first. Two naive walks would expand 5 places each: B-jump, A-xctl, C-xctl, D-xctl, B-xctl, then C-jump, B-xctl, A-xctl, C-xctl, D-xctl. That is 10 expansions. The memo makes 6.

The path is the depth-first walk's current route; the stack is Tarjan's stack of places not yet in a finished component. A-xctl's two successors are taken C-xctl first here; the other order changes the visits, not the answers.

Tarjan's walk and the memo, one move at a time (12 steps)
  1. The round's memo is empty. B-jump is asked for its answer, and it has none, so a solve starts from it.

    Path

    empty

    Stack

    empty

    Answered

    none
  2. Visit B-jump: give it index 0, low 0, and expand it (expansion 1). It finds nothing and leads to A-xctl.

    placeindexlow
    B-jump00

    Path

    B-jump

    Stack

    B-jump
  3. A-xctl is not answered and not visited: visit it, index 1, expansion 2. It leads to C-xctl and D-xctl.

    placeindexlow
    B-jump00
    A-xctl11

    Path

    B-jumpA-xctl

    Stack

    B-jumpA-xctl
  4. A-xctl's first successor, C-xctl: visit it, index 2, expansion 3. It leads to B-xctl.

    placeindexlow
    B-jump00
    A-xctl11
    C-xctl22

    Path

    B-jumpA-xctlC-xctl

    Stack

    B-jumpA-xctlC-xctl
  5. Visit B-xctl, index 3, expansion 4. It leads to A-xctl.

    placeindexlow
    B-jump00
    A-xctl11
    C-xctl22
    B-xctl33

    Path

    B-jumpA-xctlC-xctlB-xctl

    Stack

    B-jumpA-xctlC-xctlB-xctl
  6. B-xctl's successor A-xctl is already visited and still on the stack: the walk has come back round the ring. B-xctl's low drops to A-xctl's index, 1. Nothing is expanded.

    placeindexlow
    B-jump00
    A-xctl11
    C-xctl22
    B-xctl31

    Path

    B-jumpA-xctlC-xctlB-xctl

    Stack

    B-jumpA-xctlC-xctlB-xctl
  7. B-xctl has no successors left. Back out to C-xctl, which takes the lower low: 1. B-xctl's low (1) is not its index (3), so it stays on the stack: it belongs to an older place's component.

    placeindexlow
    B-jump00
    A-xctl11
    C-xctl21
    B-xctl31

    Path

    B-jumpA-xctlC-xctl

    Stack

    B-jumpA-xctlC-xctlB-xctl
  8. C-xctl is done too. Back out to A-xctl, whose low stays 1. C-xctl's low (1) is not its index (2), so it stays on the stack as well.

    Path

    B-jumpA-xctl

    Stack

    B-jumpA-xctlC-xctlB-xctl
  9. A-xctl's second successor, D-xctl: visit it, index 4, expansion 5. It finds 'PROGE', from the MOVE in PROGD, and leads nowhere.

    placeindexlow
    B-jump00
    A-xctl11
    C-xctl21
    B-xctl31
    D-xctl44

    Path

    B-jumpA-xctlD-xctl

    Stack

    B-jumpA-xctlC-xctlB-xctlD-xctl
  10. D-xctl is done and its low equals its index: it is a component on its own. Pop it. Its answer is what it found, {PROGE}. It has no successors, so a walk from it reaches nothing twice: revisits is false.

    Path

    B-jumpA-xctl

    Stack

    B-jumpA-xctlC-xctlB-xctl
    answeredanswerrevisits
    D-xctl{PROGE}false
  11. A-xctl is done and its low (1) equals its index (1). Pop the stack down to it: B-xctl, C-xctl, A-xctl are one component, the ring. None finds anything itself. The one edge leaving the component goes A-xctl → D-xctl, already answered {PROGE}. So all three get {PROGE}. A component of more than one place is a cycle, so all three revisit.

    Path

    B-jump

    Stack

    B-jump
    answeredanswerrevisits
    D-xctl{PROGE}false
    B-xctl{PROGE}true
    C-xctl{PROGE}true
    A-xctl{PROGE}true
  12. B-jump is done, low 0 equals index 0: a component on its own. Its answer is its own (nothing) joined with A-xctl's: {PROGE}. A-xctl revisits, so B-jump does. The path is empty and the solve ends after 5 expansions. The jump's answer is {PROGE}, marked cycled.

    Path

    empty

    Stack

    empty
    answeredanswerrevisits
    D-xctl{PROGE}false
    B-xctl{PROGE}true
    C-xctl{PROGE}true
    A-xctl{PROGE}true
    B-jump{PROGE}true
  13. Now PROGC's jump. C-jump is not answered, so a new solve starts: index 0, expansion 6. Its only successor, B-xctl, is already answered, so the walk does not go there. C-jump backs out at once, a component on its own: answer {PROGE}, revisits true because B-xctl does. The ring and PROGD were not expanded again.

    answeredanswerrevisits
    D-xctl{PROGE}false
    B-xctl{PROGE}true
    C-xctl{PROGE}true
    A-xctl{PROGE}true
    B-jump{PROGE}true
    C-jump{PROGE}true

A third jump starting at any of these six places would expand nothing: the memo returns the stored answer straight away. Both answers are what the naive walks give: {PROGE}, and both walks would meet a place twice going round the ring.

The gap grows with the ring. A walk from inside a ring of n places expands all n; n jumps on it expand about n² between them. With the memo the ring is expanded once. On a test of a 15-program XCTL ring, vertex expansions went from 480 to 60, with identical output.

The rule

answer(v) = own(v) ⊔ ⨆ answer(w) for every successor w of v
for a component C: answer(v) = ⨆ own(u) for u ∈ C ⊔ ⨆ answer(w) for edges u → w, u ∈ C, w ∉ C
settle C when Tarjan backs out of a place r with low(r) = index(r): C = the stack down to r
a successor already answered is not entered; each place is expanded once per memo
revisits(v) ⟺ edges out of the places v reaches ≠ (places v reaches) − 1

The first line is the naive walk's answer written per place. It holds for every member of a component at once, because they all reach the same places. Tarjan settles a component only after every component it leads to, so the answers the second line needs are always there.

The join has to be the walk's join, and it must not care about order or repetition: here, set unions of values, reasons and outside callers, and an "or" of the cycled flag. That is what lets a stored answer stand in for walking again.

The revisit flag. Besides values, the combine asks whether a walk from a place would reach some place twice. That is true exactly when what the place reaches is not a tree hanging from it: a tree on k places has k − 1 edges. Most places settle it when their component settles. A component of two or more places is a cycle, so every member revisits. A single place with an edge to itself, the same successor twice, or a successor that revisits, revisits. A single place with at most one successor, which does not revisit, does not. What is left is a single place with several successors that do not revisit, like the top of a diamond: their reach may overlap. That one is decided on demand by walking the successor lists the memo already keeps, counting edges against places. Nothing is expanded.

The combine reads the flag in one case only: when a jump's answer has no values and no reasons. A walk that went round a ring and found nothing is reporting that the bytes come from whoever first started the ring, so the target is marked as received from outside (inbound-linkage) rather than left empty with no explanation. In the example, without PROGD, both jumps would end that way.

What goes wrong

In cobble

The combine (cobble-dataflow-combine) holds two memos. The first answers jumps across programs. Each round builds one over the places it reaches: it resolves a place inside its program and follows each stop across links. A place met again on a cycle is flagged, and an empty answer so flagged becomes inbound-linkage. Links are found through an index by (callee, parameter), (caller, node, parameter) and (callee, node), not by a scan. The second memo works inside one program and lasts the whole run. Both tell places apart by their bytes alone, not by the field name that reached them.

The command and its options are in the field dataflow reference. Related pages: fixpoints, traversal orders, hop limits.