Chapter 6: Structured Dagger (SDAG)¶
The programs you wrote in the previous exercises — parallel prefix sum and odd-even
transposition sort — are correct, but they share an uncomfortable structural property.
The logic of each chare is fundamentally sequential: "first do step 1, then wait for a
message, then do step 2, ..." Yet the code does not look sequential at all. It is
spread across a constructor, a private advance() method, and a receiveVal entry
method, with a message buffer and a flag or two to stitch them together. Every time
the runtime delivers a message, receiveVal figures out what state the chare is in,
applies the received value, and re-enters advance() to push the chare forward.
The code works, but reasoning about it requires holding the entire state machine in your
head simultaneously. If you wanted to add a third phase, you would need to extend the
buffer, add another flag, and add another case to advance().
Structured Dagger (SDAG) is a Charm++ extension that lets you write the chare's lifecycle as a sequential program — one that can block waiting for a message, then resume. The underlying execution model is unchanged (messages still arrive in any order, the scheduler still runs other chares while a chare is "blocked"), but the code reads like a straight-line program.
The Idea¶
In SDAG, the body of certain entry methods is written directly in the .ci file, not
in the .C file. The Charm++ interface compiler (charmc) translates those bodies into
a state machine — but you never see the state machine. You see the sequential version.
Two constructs do the work:
-
serial { ... }— a block of ordinary C++ code. The scheduler cannot interrupt execution inside a serial block; from the chare's point of view, it runs atomically. -
when method(args) serial { ... }— a blocking receive. Execution suspends here until an invocation ofmethodon this chare arrives. When it does, the body runs. If the message has already arrived beforewhenis reached, it is consumed immediately without suspending.
The SDAG runtime buffers messages that arrive before a when is ready to consume them.
You do not manage the buffer; you just write when.
A Minimal Example¶
Here is the simplest use of SDAG: a chare that sends a value to another chare and then waits for a reply before printing.
// .ci file
array [1D] Ping {
entry Ping();
entry void reply(int val);
entry void start() {
serial {
pongArray[thisIndex ^ 1].ping(myVal);
}
when reply(int val) serial {
CkPrintf("[%d] received %d\n", thisIndex, val);
}
serial {
contribute(CkCallback(CkReductionTarget(Main, done), mainProxy));
}
};
};
// .C file
class Ping : public CBase_Ping {
int myVal;
public:
Ping_SDAG_CODE
Ping() : myVal(thisIndex + 1) {}
Ping(CkMigrateMessage* m) {}
};
The .ci body of start() reads like a sequential program. The when reply(...) line
blocks until the other chare sends a reply message. Everything after it runs in order.
serial Blocks¶
A serial block is arbitrary C++. It can call any function, modify member variables,
send messages to other chares, or call Charm++ utilities:
serial {
int x = compute(value);
value += x;
otherProxy.sendData(value);
CkPrintf("[%d] value is now %d\n", thisIndex, value);
}
Within a serial block the scheduler cannot deliver any other message to this PE.
The block should therefore be short — it is not a place for long computation. Long
computation in a serial block stalls all other chares on the same PE.
when Clauses and Buffering¶
A when clause designates an entry method as the source of the next expected message:
If the message has not arrived yet, the Charm++ runtime returns this chare's thread to the scheduler and resumes it when the message arrives. Any other chares on this PE continue to run normally.
If the message has already arrived (because the sender was fast), when finds it in
the runtime's buffer and proceeds without suspending. Either way, from the chare's
perspective, when simply blocks and then continues.
A crucial consequence: the order of when clauses in the .ci body defines the
order in which messages are consumed, regardless of the order they arrive. The runtime
buffers all early arrivals. This is the SDAG replacement for the manual message-buffer
map (std::map<int,int> recvBuf) you wrote in the prefix sum.
Reference Numbers¶
The buffering just described handles a single when per entry method cleanly, but what
about a loop that receives many messages of the same type, one per iteration?
// naive — WRONG in a loop
for (round = 0; round < N; round++) {
serial { neighbor.update(round, value); }
when update(int r, int val) serial { value += val; }
}
The problem: all arriving update messages go into the same pool. When the when in
round 0 fires, it might consume a message from round 5. The result is incorrect.
SDAG solves this with reference numbers. The first int parameter of an entry
method is its implicit reference number. When you write when update[round](...), the
when only matches a message whose first argument equals the current value of round:
// correct — with reference numbers
for (round = 0; round < N; round++) {
serial { neighbor.update(round, value); }
when update[round](int r, int val) serial { value += val; }
}
Now when update[round] in iteration 3 will only consume an update message whose
first argument is 3. Messages from future rounds (round = 5, 7, ...) are buffered by
the SDAG runtime and delivered when the loop reaches those iterations. You no longer
need a buffer, flags, or a re-entrant advance().
This is the mechanism that replaces the entire bufRound/bufVal scalar buffer from
the odd-even sort, and the recvBuf map from the prefix sum.
SDAG for Loops¶
SDAG supports for, while, and if constructs inside entry method bodies. The for
loop follows C++ syntax, but with one constraint: the loop variable must be a class
member, not declared in the loop header. This is because the SDAG compiler needs to
store the variable between suspension points.
// .ci
entry void start() {
for (dist = 1; dist < numElements; dist <<= 1) {
serial { /* ... */ }
when recv[dist](int ref, int val) serial { /* ... */ }
}
};
// .C — dist must be a member variable
class Foo : public CBase_Foo {
int dist; // loop variable must be a member
...
};
Any combination of serial, when, and if blocks can appear inside the loop body.
Iterations run sequentially: the body of iteration k completes (including any when
suspensions) before iteration k+1 begins. This is correct for algorithms like prefix
sum and odd-even sort, where each step depends on the previous one.
SDAG if¶
SDAG if works exactly as you expect. It can gate serial blocks, when clauses, or
nested constructs:
if (thisIndex + dist < numElements) serial {
otherProxy[thisIndex + dist].send(dist, value);
}
if (thisIndex >= dist)
when recv[dist](int ref, int val) serial {
value += val;
}
If the condition references values computed in a preceding serial block, those values
must be stored in member variables (not local variables) so the SDAG compiler can access
them across suspension points. For example, in odd-even sort, amLeft and amRight are
set in a serial block and then used in the if condition that gates the when:
// .ci
for (round = 0; round < numElems; round++) {
serial {
int offset = round % 2;
amLeft = ((thisIndex % 2) == offset) && (thisIndex + 1 < numElems);
amRight = ((thisIndex % 2) != offset) && (thisIndex > 0);
// ... send if active ...
}
if (amLeft || amRight)
when receiveVal[round](int r, int partnerVal) serial {
if (amLeft) value = std::min(value, partnerVal);
else value = std::max(value, partnerVal);
}
}
// .C — amLeft and amRight must be members
class Sorter : public CBase_Sorter {
int value, round;
bool amLeft, amRight;
...
};
IDLE elements — those where both amLeft and amRight are false — skip the when
entirely for that round. The loop counter increments and the chare continues to the
next round without waiting for any message. No special handling is required; the if
does the right thing.
The ClassName_SDAG_CODE Macro¶
Any chare class that contains SDAG code must include the macro ClassName_SDAG_CODE
inside its class body:
class Prefix : public CBase_Prefix {
public:
Prefix_SDAG_CODE // required — generates entry points and scheduler hooks
Prefix() { ... }
Prefix(CkMigrateMessage* m) {}
};
The macro is generated by charmc from the .ci file. It inserts the internal
machinery that implements the when buffering and the suspension/resume mechanism.
Omitting it causes a compile error. There is nothing else to do; the macro takes care
of everything.
Complete Example: Parallel Prefix Sum¶
The SDAG version of the parallel prefix sum lives in examples/prefix_sdag/. Here is
the .ci file in full, with annotations.
mainmodule prefix {
readonly CProxy_Main mainProxy;
readonly int numElements;
readonly CProxy_Prefix prefixArray;
mainchare Main {
entry Main(CkArgMsg*);
entry [reductiontarget] void done();
};
array [1D] Prefix {
entry Prefix();
entry void passValue(int stepDist, int val); // (1)
entry void start() { // (2)
for (dist = 1; dist < numElements; dist <<= 1) { // (3)
if (thisIndex + dist < numElements) serial { // (4)
usleep(rand() % 5001);
prefixArray[thisIndex + dist].passValue(dist, value);
}
if (thisIndex >= dist) // (5)
when passValue[dist](int stepDist, int val) serial { // (6)
value += val;
}
}
serial { // (7)
int expected = (thisIndex + 1) * (thisIndex + 2) / 2;
CkPrintf("Prefix[%d] = %d (expected %d) %s\n",
thisIndex, value, expected,
(value == expected) ? "OK" : "WRONG");
CkCallback cb(CkReductionTarget(Main, done), mainProxy);
contribute(0, NULL, CkReduction::nop, cb);
}
};
};
};
(1) passValue(int stepDist, int val) — the first parameter is int, so
stepDist is the reference number. When this chare sends
prefixArray[i].passValue(dist, value), the message is tagged with the value dist.
(2) start() is an SDAG entry method — its body is written here in the .ci file.
Main calls prefixArray.start() to kick off the algorithm.
(3) The loop variable dist is a class member. The step dist <<= 1 doubles
the stride each iteration, giving the log(N) steps of recursive doubling.
(4) The right-neighbor send. The if condition ensures element N-1 does not try
to send out of bounds. The serial block includes a random delay (usleep) to stress-
test that out-of-order delivery is handled correctly.
(5) The left-neighbor receive. Elements with no left neighbor at this step (those
with thisIndex < dist) skip the when entirely.
(6) when passValue[dist] — the [dist] tag means: match only a passValue
message whose first argument equals the current value of dist. Messages arriving early
(from a larger step) are held in the SDAG buffer until the loop reaches the matching
iteration.
(7) After all steps complete, every element has its prefix sum. Print and contribute
to a no-op reduction to signal Main that the algorithm is finished.
The corresponding .C file is remarkably thin:
#include "prefix.decl.h"
#include <unistd.h>
/*readonly*/ CProxy_Main mainProxy;
/*readonly*/ CProxy_Prefix prefixArray;
/*readonly*/ int numElements;
class Main : public CBase_Main {
public:
Main(CkArgMsg* msg) {
numElements = (msg->argc > 1) ? atoi(msg->argv[1]) : 8;
delete msg;
mainProxy = thisProxy;
prefixArray = CProxy_Prefix::ckNew(numElements);
prefixArray.start(); // kick off the SDAG entry method
}
void done() { CkExit(); }
};
class Prefix : public CBase_Prefix {
int value, dist; // dist is the SDAG for-loop variable; must be a member
public:
Prefix_SDAG_CODE
Prefix() : value(thisIndex + 1), dist(0) {
srand(thisIndex * 1009 + 7);
}
Prefix(CkMigrateMessage* m) {}
};
#include "prefix.def.h"
Compare this with the manual version in examples/prefix/prefix.C: no recvBuf map,
no sentAtCurrentDist flag, no isDone guard, no advance() method. The logic has
moved into the .ci file where it reads sequentially.
Before and After: Odd-Even Transposition Sort¶
The cleanup is even more striking for the odd-even sort, where the manual version required a carefully-reasoned scalar buffer to prevent deadlock.
Manual (examples/oddevensort/) |
SDAG (examples/oddevensort_sdag/) |
|---|---|
bufRound, bufVal — one-slot buffer for a future-round message |
automatic — SDAG runtime buffers by reference number |
sentRound flag — prevents re-sending on re-entry |
one serial per loop iteration, executes once |
advance() — re-entrant, called from constructor and receiveVal |
eliminated — start() in the .ci is the control flow |
receiveVal(int partnerVal, int r) — splits current/future round handling |
receiveVal(int round, int val) — round first, plain receive |
| 115 lines total | 45 lines total |
In the manual version, the argument to receiveVal was (int val, int round) — value
first, round second. The SDAG version reverses this to (int round, int val) because
the reference number mechanism uses the first int parameter. This is a mechanical
consequence of the tagging rule, not an algorithmic change.
The proof of buffer sufficiency (at most one future-round message can be in flight at any time) was necessary to justify the one-slot buffer in the manual version. In the SDAG version, that proof is no longer needed: SDAG buffers arbitrarily many early- arriving messages. The program is correct for a more general reason.
Exercise: Choose One¶
Convert one of your two manual implementations to use SDAG. Both exercises teach the same constructs. Choose based on which implementation you found harder to write and reason about.
Option A: Parallel Prefix Sum¶
Convert examples/prefix/ to use SDAG. Start with a new directory so you can compare.
Key steps:
- Add an
entry void start()body to the.cifile. - Write a
for (dist = 1; dist < numElements; dist <<= 1)loop in the body. - Inside the loop, send to the right neighbor (inside a
serialblock) and receive from the left neighbor usingwhen passValue[dist](...). - After the loop, print the result and call
contribute(inside aserialblock). - In the
.Cfile, declaredistas a member variable. PlacePrefix_SDAG_CODEin the class body. HaveMaincallprefixArray.start(). - Remove
advance(),recvBuf,sentAtCurrentDist, andisDone.
The result should closely resemble Figure 7.3 in the SDAG chapter of the Charm++ tutorial book.
Option B: Odd-Even Transposition Sort¶
Convert examples/oddevensort/ to use SDAG.
Key steps:
- Add an
entry void start()body to the.cifile. - Write a
for (round = 0; round < numElems; round++)loop. - Inside the loop, compute
amLeftandamRightin aserialblock and send if active. - Gate the
whenwithif (amLeft || amRight):when receiveVal[round](int r, int partnerVal) serial { ... } - Swap the parameter order in
receiveValto(int round, int val)— round must be first to serve as the reference number. - Declare
round,amLeft,amRightas class members. - Remove
advance(),bufRound,bufVal,sentRound.
IDLE elements (both amLeft and amRight false) will skip the when via the if
condition. No special-casing is needed.
Testing¶
Build with make and run:
./charmrun ++local +p4 ++ppn 2 ./program 8
./charmrun ++local +p4 ++ppn 2 ./program 16
./charmrun ++local +p2 ++ppn 2 ./program 8
All elements should print OK. The print order will vary between runs (chares complete
in non-deterministic order), but all values should be correct.
Further Exercise: K-Means Clustering¶
Implement Lloyd's algorithm for K-means clustering as a Charm++ program.
Setup. Each of N chares owns a subset of M/N data points in 2D space. The main chare holds K cluster centroids (initially chosen at random). Each iteration:
- Main broadcasts the current centroids to all chares.
- Each chare assigns every local point to its nearest centroid, then contributes two reductions back to Main: the per-cluster point count and the per-cluster coordinate sum.
- Main receives both reductions, recomputes the centroids (sum / count), and starts the next iteration if the centroid shift exceeds a convergence threshold.
Charm++ concepts exercised. SDAG for loop (or while) for iterations, array
parameters for broadcasting centroids and receiving partial sums, reductions with custom
data, and the interaction between broadcasts and reductions within a single iteration.
Challenge. A single iteration requires two separate reductions (counts and coordinate sums). Think carefully about how Main coordinates receiving both before recomputing centroids.
Parameters. Accept M (total points), N (number of chares), and K (clusters)
on the command line. Print the final centroids and the number of iterations to convergence.
SDAG and the Chare Lifecycle¶
A common misconception: SDAG changes the execution model.
It does not. Messages still arrive in any order. The scheduler still interleaves chares
on each PE. serial blocks run without interruption, but only within a single block —
between any two sequential statements that span a when, the scheduler may run any
number of other chares.
What SDAG provides is:
- A sequential notation for control flow that spans multiple message receives.
- A runtime-managed buffer, keyed by reference number, that holds early-arriving
messages until the matching
whenis ready to consume them.
The state machine that the manual version forced you to implement by hand is generated
for you by charmc. You write the sequential intent; the compiler generates the correct
asynchronous implementation.
Further Constructs¶
This chapter covered serial, when, for, and if — sufficient for the exercises
and for most practical SDAG programs. Three additional constructs exist for more
complex dependency patterns:
forall [i] (lo:hi, stride) { ... }— iterations that are independent of each other and can execute in any order (though not in parallel).overlap { ... }— a block containing multiple SDAG statements that may execute in any order; control exits when all of them have completed.case { when A(...) {...} when B(...) {...} }— a disjunction: execute whichever branch's message arrives first.
These are documented in the Charm++ readthedocs manual and in the SDAG chapter of the tutorial book.
Summary¶
SDAG lets you write the sequential logic of a chare's lifecycle directly in the .ci
file, using serial and when to express C++ computation and message receives.
The key points:
serial { ... }— atomic C++ block; the scheduler cannot preempt it.when method(args) serial { ... }— blocking receive; runtime buffers early arrivals.when method[ref](int tag, ...)— tagged receive; only matches messages whose firstintargument equalsref. Essential for loops with one receive per iteration.- The
forloop variable must be a class member. ifconditions that depend on values set in precedingserialblocks must store those values in member variables.- Every class with SDAG code must include
ClassName_SDAG_CODEin its class body. - SDAG entry method bodies go in the
.cifile; the.Cfile holds only member declarations and the constructor.
The underlying execution model is unchanged. SDAG is a notation for expressing
sequential intent; charmc translates it into correct asynchronous code.