Apalache trail tips: how to check your specs faster

Difficulty: Red trail – Medium

This tutorial collects tips and tricks that demonstrate the strong sides of Apalache.

Tip 1: Use TLA+ constructs instead of explicit iteration

The Apalache antipatterns mention that one should not use explicit iteration (e.g., ApaFoldSet and ApaFoldSeqLeft), unless it is really needed. In this tip, we present a concrete example that demonstrates how explicit iteration slows down Apalache.

In our example, we model a system of processes from a set Proc that are equipped with individual clocks. These clocks may be completely unsynchronized. However, they get updated uniformly, that is, all clocks have the same speed.

Let's have a look at the first part of this specification:

-------------------------- MODULE FoldExcept ----------------------------------
(*
 * This specification measures performance in the presence of an anti-pattern.
 *)

EXTENDS Integers, Apalache 

CONSTANT
    \* A fixed set of processes
    \*
    \* @type: Set(Str);
    Proc

VARIABLES
    \* Process clocks
    \*
    \* @type: Str -> Int;
    clocks,
    \* Drift between pairs of clocks
    \*
    \* @type: <<Str, Str>> -> Int;
    drift

As we can see, the constant Proc is a specification parameter. For instance, it can be equal to { "p1", "p2", "p3" }. The variable clocks assigns a clock value to each process from Proc, whereas the variable drift collects the clock difference for each pair of processes from Proc. This relation is easy to see in the predicate Init:

Init ==
    /\ clocks \in [ Proc -> Nat ]
    /\ drift = [ <<p, q>> \in Proc \X Proc |-> clocks[p] - clocks[q] ]

Further, we write down a step of our system:

\* Uniformly advance the clocks and update the drifts.
\* Constructing functions without iteration.
NextFast ==
    \E delta \in Nat \ { 0 }:
        /\ clocks' = [ p \in Proc |-> clocks[p] + delta ]
        /\ drift' = [ <<p, q>> \in Proc \X Proc |-> clocks'[p] - clocks'[q] ]

The transition predicate NextFast uniformly advances the clocks of all processes by a non-negative number delta. Simultaneously, it updates the clock differences in the function drift.

It is easy to see that drift actually does not change between the steps. We can formulate this observation as an action invariant:

\* Check that the clock drifts do not change
DriftInv ==
    \A p, q \in Proc:
        drift'[p, q] = drift[p, q]

Our version of NextFast is quite concise and it uses the good parts of TLA+. However, new TLA+ users would probably write it differently. Below, you can see the version that is more likely to be written by a specification writer who has good experience in software engineering:

\* Uniformly advance the clocks and update the drifts.
\* Constructing functions via explicit iteration. More like a program.
NextSlow ==
    \E delta \in Nat \ { 0 }:
        /\  LET \* @type: (Str -> Int, Str) => (Str -> Int);
                IncrementInLoop(clk, p) ==
                [ clk EXCEPT ![p] = @ + delta ]
            IN
            clocks' = ApaFoldSet(IncrementInLoop, clocks, Proc)
        /\  LET \* @type: (<<Str, Str>> -> Int, <<Str, Str>>)
                \*          => <<Str, Str>> -> Int;
                SubtractInLoop(dft, pair) ==
                LET p == pair[1]
                    q == pair[2]
                IN
                [ dft EXCEPT ![p, q] = clocks'[p] - clocks'[q] ]
            IN
            drift' = ApaFoldSet(SubtractInLoop, drift, Proc \X Proc)

The version NextSlow is less concise than NextFast, but it is probably easier to read for a software engineer. Indeed, we update the variable clocks via a set fold, which implements an iteration over the set of processes. What makes it easier to understand for a software engineer is a local update in the operator IncrementInLoop. Likewise, the variable drift is iteratively updated with the operator SubtractInLoop.

If ApaFoldSet looks unfamiliar to you, check the page on folding sets and sequences.

Although NextSlow may look more familiar, it is significantly harder for Apalache to check than NextFast. To see the difference, we measure performance of Apalache for several sizes of Proc: 3, 5, 7, and 10. We do this by running Apalache for the values of N equal to 3, 5, 7, 10. To this end we define several model files called MC_FoldExcept${N}.tla for N=3,5,7,10. For instance, MC_FoldExcept3.tla looks as follows:

--------------------- MODULE MC_FoldExcept3 ---------------------------------

Proc == { "p1", "p2", "p3" }

VARIABLES
    \* Process clocks
    \*
    \* @type: Str -> Int;
    clocks,
    \* Drift between pairs of clocks
    \*
    \* @type: <<Str, Str>> -> Int;
    drift

INSTANCE FoldExcept    

==============================================================================

We run Apalache for different instances of N:

apalache-mc check --next=NextSlow --inv=DriftInv MC_FoldExcept${N}.tla
apalache-mc check --next=NextFast --inv=DriftInv MC_FoldExcept${N}.tla

The plot below shows the running times for the versions NextSlow and NextFast:

Running times

The plot speaks for itself. The version NextFast is dramatically faster than NextSlow for an increasing number of processes. Interestingly, NextFast is also more concise. In principle, both NextFast and NextSlow describe the same behavior. However, NextFast looks higher-level: It looks like it computes clocks and drifts in parallel, whereas NextSlow computes these functions in a loop (though the order of iteration is unknown). Actually, whether these functions are computed sequentially or in parallel is irrelevant for our specification, as both NextFast and NextSlow describe a single step of our system! We can view NextSlow as an implementation of NextFast, as NextSlow contains a bit more computation details.

From the performance angle, the above plot may seem counterintuitive to software engineers. Indeed, we are simply updating an array-like data structure in a loop. Normally, it should not be computationally expensive. However, behind the scenes, Apalache is producing constraints about all function elements for each iteration. Intuitively, you can think of it as being fully copied at every iteration, instead of one element being updated. From this perspective, the iteration in NextSlow should clearly be less efficient.