Topic — Debugging and profiling a multi-PE run
Needs: Lesson 3 — How a PE runs a loop: vector instructions and cycles, Lesson 6 — Blocking, buffering and deadlock.
A multi-PE program goes wrong in one of three ways. It stops, because a PE waits for data that never comes. It finishes with wrong values. Or it is correct and slower than you expect. In each case the test tells you only that the result differs. This topic shows how to make a program report, PE by PE, how far it got, what it received and where its cycles went. It ships a correct program that does this, and three faults for you to apply and find.
What you will be able to do
- Read each PE's progress marker and received count after a run, and find the first link that did not deliver.
- Read from the build log which physical color Paint gave each socket.
- Find an out-of-bounds array write with the compiler option
fsanitize=bounds. - Program a PE's two performance counters, and use them with the phase times to find which PE limits a run.
The program
Three PEs in a row compute a histogram and its running sum:
x=0 x=1 x=2
+-------+ ab +--------+ bc +-------+
| src | ----> | hist | -----> | dst |
+-------+ +--------+ +-------+
N samples NB bins running sum of the bins
srcsends N samples. Each is an integer from 0 to 2·NB − 1.ref.pymakes them, with a fixed seed, and the Makefile loads them intosrc.awithmiset.histreceives the samples intoa, counts each one into bina[i] >> 1ofh, so each bin is two values wide, and sends the NB counts on.dstreceives the counts intorand stores their running sum inc. The last element ofcis N.
The test compares hist.h and dst.c with the histogram that check.py computes from the same
samples. Before it compares, it prints one line per PE.
The code
Progress markers and counters: perf.w
Every kernel imports perf.w (lesson 2's import):
xp tsc[3] address(0x7b30);
xp counter[8] address(0x7b20);
xp counter_control[1] address(0x7b34);
xp counter_config[4] address(0x7b38);
xp step[1];
xp stamp[n=6];
xp count[n=2];
function mark(xp k) {
step[0] = k;
stamp[k] = tsc[0];
}
function counters_start() {
counter_config[0] = 1 | (1 << 14);
counter_config[1] = 0;
counter_config[2] = 17 | (31 << 5);
counter_config[3] = 0;
counter[0] = 0;
counter[4] = 0;
counter_control[0] = 7;
mark(0);
}
function counters_stop() {
count[0] = counter[0];
count[1] = counter[4];
}
A function without -> type returns nothing. mark(k) records that the PE has reached phase
k. It writes k into step and the low 16 bits of the timestamp counter (lesson 3) into
stamp[k]. After the run, step holds the last
phase the PE reached, and the difference of two stamps is the length of a phase in cycles.
The markers are one-element arrays because miget reads memory. The compiler may keep a global
scalar such as xp step; in a register. It then has no entry in bench.map, and miget cannot
read it: a version of this program that declared xp step; had no step in its map.
Each PE has two performance counters besides the timestamp counter. They live at fixed addresses,
like the timestamp counter, and the address() arrays above map them:
| Array | Address | What it holds |
|---|---|---|
counter[0..3] | 0x7b20 | counter 0, 48 bits in three words, low word first; the fourth word is always 0 |
counter[4..7] | 0x7b24 | counter 1, the same layout |
counter_control[0] | 0x7b34 | bit 0 enables counter 0, bit 1 counter 1, bit 2 the timestamp counter |
counter_config[0..1] | 0x7b38 | what counter 0 counts: a 32-bit word, low half first |
counter_config[2..3] | 0x7b3a | what counter 1 counts |
In a configuration word, bits 4 to 0 select an event type and bits 20 to 5 refine it.
counters_start() selects two events:
- Counter 0,
1 | (1 << 14). Event type 1 counts cycles by how many operations the PE's core executed in them. Bit 14 selects the cycles in which it executed none, because it was idle or its pipeline was stalled. This is theidlecolumn below. - Counter 1,
17 | (31 << 5). Event type 17 counts cycles in which a color's buffer in this PE's router held a wavelet it could not pass on, toward a neighbour or into this PE's core. Bits 9 to 5 select the color, and 31 selects all colors. This is thestalledcolumn.
counters_start() clears the low words of both counters, then writes 7 to the control register,
which starts both counters and the timestamp counter. counters_stop() copies the low words into
count, an ordinary array, where the test can read them. Read the low word of a counter first:
reading a higher word first returns undefined data. A 16-bit count wraps after 65,536, which the
runs here stay far below. The counters start with the timestamp counter, at the write of 7, and
stop being read a few instructions after the last stamp, so they cover a few cycles more than the
cycles column. Compare the two only roughly.
The kernels
hist.w shows the pattern every kernel follows:
import perf;
xp param N;
xp param NB;
socket rx;
socket tx;
xp a[n=N];
xp h[n=NB];
main {
let i in [0, N);
let b in [0, NB);
counters_start();
∀i a[i] = -1;
mark(1);
∀i a[i] <- rx[];
mark(2);
∀i h[a[i] >> 1] += 1;
mark(3);
∀b tx[] <- h[b];
mark(4);
counters_stop();
}
∀i a[i] = -1;fills the receive buffer with a value no sample can take. After a run, the number of entries that are no longer −1 is the number of samples that arrived.dstfillsrthe same way.mark(1)tomark(4)divide the work into phases: receiving, binning, sending and finished.srchas the phases sending and finished, anddstreceiving, summing and finished.- The table's first phase,
start, runs frommark(0), the last step ofcounters_start(), tomark(1). Inhistanddstit fills the receive buffer with −1. counters_stop()runs only if the PE gets to the end. A PE that stops early leaves its markers, but no counts.
The layout
bench.paint is lesson 4's pipeline with three PEs:
let ab = color()
let bc = color()
let src = tile()
src : code('src [tx:ab N:N])
src : paint(ab [C>R])
src : name('src)
let hist = tile()
hist : code('hist [rx:ab tx:bc N:N NB:NB])
hist : paint(ab [L>C])
hist : paint(bc [C>R])
hist : name('hist)
let dst = tile()
dst : code('dst [rx:bc NB:NB])
dst : paint(bc [L>C])
dst : name('dst)
hstack(src hist dst)
The test
The Makefile reads every symbol the check needs from the final memory, one file per symbol, and
runs check.py on them:
SYMBOLS=src.step hist.step dst.step src.stamp hist.stamp dst.stamp src.count hist.count dst.count hist.a hist.h dst.r dst.c
test/$(MODE): bench-$(MODE).core bench.map
for s in $(SYMBOLS); do miget image=$< map=bench.map symbol=$$s data=$$s.bin || exit 1; done
python3 ../check.py $(N) $(NB)
check.py prints a table. step and phase are the last marker. received counts the entries
of hist.a and dst.r that are no longer −1. cycles, idle and stalled are shown only for a
PE that finished: cycles is from its first stamp to its last, and the other two are the
counters. The last column gives the cycles of each phase the PE completed.
The rows are tutorial (N=64, NB=8) and n256 (N=256, NB=16). The cycle cap is 8000 cycles.
Run it
In your copy of the tutorial (see Lesson 1):
cd tutorial/ext-layouts-debugging/debugging
make TEST=tutorial
(cd test-tutorial && python3 ../check.py 64 8)
The last line runs check.py again in the row's directory, where the Makefile left the files it
read. This Makefile names bench-$(MODE).core in its test rule, so make also keeps the simulated
memory, test-tutorial/bench-sim.core, for your own miget commands; lesson 4's Makefile does
not.
make prints a line ending in PASS tutorial [ N=64 NB=8 ARCH=sdr ]. The table:
PE step phase received cycles idle stalled cycles per phase
src 2 finished 40 21 0 start 3, sending 37
hist 4 finished 64/64 837 348 0 start 13, receiving 42, binning 778, sending 4
dst 3 finished 8/8 890 846 0 start 6, receiving 850, summing 34
hist.h matches
dst.c matches
make TEST=n256 runs the second row; check.py then takes 256 16.
Reading a profile
The table answers "which PE limits this run":
histis busy: its core executes something in about 490 of its 837 cycles, and binning takes 778 of them, about 12 cycles per sample. Each sample's bin address comes from the data, so the compiler builds a loop that reads a sample, reads its bin, and writes the bin back, one sample at a time. The listing intest-tutorial/hist_artefacts/shows the loop (lesson 3 shows how to read a listing). Part ofhist's idle count is that loop's pipeline stalls, not waiting for data: counter 0 counts both.dstis idle for 846 of its 890 cycles. It waits 850 cycles in receiving, for counts that come only afterhisthas binned every sample.srcfinishes after 40 cycles.histreceives the samples as fast assrcsends them, sosrcnever waits for room.stalledis 0 everywhere: no wavelet sat in a router. At N=256,srcandhistshow 8 and 9 stalled cycles, and binning grows to 3082 cycles for 256 samples.
So hist's binning loop is the work to speed up. The phase times and the order of the streams
show it: dst's long receiving phase waits for hist's binning, and nothing waits for src.
Counter 0 alone does not identify such work, because it counts pipeline stalls together with
waiting. When a PE consumes its input more slowly than it arrives, the buffers in front of it fill,
and the routers on the way to it, its own included, show stalled cycles.
Find three faults
faults/ holds three changed files. Each makes one mistake that is easy to make and that the
build does not report. Each replaces one file of the topic, so save that file first and put it back
afterwards. For fault 1, in tutorial/ext-layouts-debugging/debugging:
cp bench.paint bench.paint.orig
cp faults/1-wrong-color/bench.paint .
make clean
make TEST=tutorial
(cd test-tutorial && python3 ../check.py 64 8)
cp bench.paint.orig bench.paint
Faults 2 and 3 replace hist.w: save it with cp hist.w hist.w.orig, apply
faults/2-off-by-one/hist.w or faults/3-out-of-bounds/hist.w the same way, and copy
hist.w.orig back when you are done. make clean comes before every run after a change, because
make does not rebuild an existing test-tutorial/.
Each fault makes make print FAIL tutorial. A run that stops runs to the cycle cap, so a faulty
run takes longer than a correct one. Work out each fault from the table before you read the
answers below. The method:
- Did every PE reach its last phase? A PE that did not is waiting for something.
- Start at
srcand walk along the data's path. The first PE that did not finish, and itsreceivedcount, tell you which link failed and how many values crossed it. - Compare the count with the count the sender should send. None means a route or a color; all but a few means a count; all of them means the receiver itself.
- Check the colors of that link: the build log lists them (fault 1).
- Only then read the sender's and the receiver's code.
Fault 1
PE step phase received cycles idle stalled cycles per phase
src 2 finished 40 21 0 start 3, sending 37
hist 4 finished 64/64 837 348 0 start 13, receiving 42, binning 778, sending 4
dst 1 receiving 0/8 - - - start 6
hist.h matches
dst.c expected [6, 12, 20, 30, 39, 44, 56, 64]
dst.c got [0, 0, 0, 0, 0, 0, 0, 0]
hist sent all its counts and finished; dst received none. Where did the eight counts go?
Fault 2
PE step phase received cycles idle stalled cycles per phase
src 2 finished 40 21 0 start 3, sending 37
hist 4 finished 64/64 837 348 0 start 13, receiving 42, binning 778, sending 4
dst 1 receiving 7/8 - - - start 6
hist.h matches
dst.c expected [6, 12, 20, 30, 39, 44, 56, 64]
dst.c got [0, 0, 0, 0, 0, 0, 0, 0]
dst received seven of its eight counts.
Fault 3
PE step phase received cycles idle stalled cycles per phase
src 2 finished 40 21 0 start 3, sending 37
hist 4 finished 64/64 837 348 0 start 13, receiving 42, binning 778, sending 4
dst 3 finished 8/8 890 846 0 start 6, receiving 850, summing 34
hist.h expected [6, 6, 8, 10, 9, 5, 12, 8]
hist.h got [4, 2, 3, 3, 6, 2, 1, 9]
dst.c expected [6, 12, 20, 30, 39, 44, 56, 64]
dst.c got [4, 6, 9, 12, 18, 20, 21, 30]
Every PE finished, but the bins add up to 30, not 64. Where did 34 samples go? Run make clean,
then build it again with the bounds check:
make TEST=tutorial PAINTOPTS='toolflag w fsanitize=bounds'
PAINTOPTS is added to Paint's command line by the Makefile. toolflag w passes the option to
the compiler for every .w kernel, next to the options the build passes itself.
Checking array bounds
The compiler checks array indices in one case only: it rejects a ∑ whose subscripts read
outside an operand array. Every other out-of-bounds access compiles without a message, a constant
index too: with xp a[n=4]; xp b[n=4];, a[7] = 9; writes b[3] (see
Array Bounds Verification).
An out-of-bounds write is not an error: it writes the memory after the array, whatever that holds.
fsanitize=bounds adds a check before every array access the compiler cannot prove in bounds. A
failed check stops the PE at a breakpoint instruction, followed by a jump to itself, so the PE
never runs past it. In the listing it looks like this:
handler main_bb5 {
brk ;
jmp main_bb5;
}
The check makes code slower. With it, the correct program's binning takes 2316 cycles instead of
778 at N=64. At N=256 binning no longer fits in the 8000-cycle cap: the run
stops while hist is still binning, which looks like a fault. Check with the tutorial row, or
raise the cap for one run:
make clean
make TEST=n256 PAINTOPTS='toolflag w fsanitize=bounds' CYCLES=20000
The Makefile sets the cap with CYCLES ?= 8000, so CYCLES= on the command line or in a row of
test.rc replaces it. The check does not cover accesses inside parallel arms.
Pitfalls
- A sender that finished proves nothing about delivery. Its wavelets can sit in queues and
router buffers. In fault 1,
histfinished while all eight counts sat in buffers on the way todst. - A run that reaches the cycle cap looks like a stopped run. Before you look for a deadlock, check whether the slowest PE was still making progress: its marker, its partial results.
- Paint does not check that a sender and a receiver use the same color. The build log does show it: see the answer to fault 1.
- Without the bounds check, an out-of-bounds write changes whatever follows the array. Here it happened to be unused memory; elsewhere it is another variable or the program's code.
- Keep markers in memory. Use one-element arrays, not scalar globals.
- Copy counters to memory before the PE stops.
counters_stop()must run; a PE that waits forever never reports its counts.
Other tools
The debugging guide describes tools that read a log of
the whole simulation rather than the final memory: a debug build with GOAL=debugx, the Portrait
viewer, pipeline traces, wavelet landing records with debug=landing, and meminsp, which
replays memory writes cycle by cycle. They depend on what the simulator records, so they are not
shown here. The markers and counters of this topic need only the final memory, and the counters
are part of every PE.
Exercise
Make counter 0 count the wavelets this PE's router delivers to its own core: in perf.w, set
counter_config[0] = 15 | (1 << 9);. Event type 15 counts the wavelets the router sends out of
the ports that bits 9 to 5 select, and bit 9 is the port into the core. Run the tutorial row
again. The idle column now holds that count: why does hist, which received 64 samples, show
32, and dst 4?
Answers
Fault 1, wrong color. faults/1-wrong-color/bench.paint binds dst's rx to ab, the color
src sends on, instead of bc:
dst : code('dst [rx:ab NB:NB])
Paint accepts it without a message. dst's input queue now takes ab, which has no route on its
PE, and the counts arriving on bc have no queue to enter. The build log shows the physical color
each socket got. With verbose, which the Makefile passes, it lists one compiler command per
kernel:
grep 'ucid=[1-9]' test-tutorial/make-log | grep -o 'set=[rt]x={.color=[0-9]*\|source=[^ ]*'
Each kernel's colors come before its source= line. In fault 1 the command prints:
set=tx={.color=23
source=../src.w
set=rx={.color=23
set=tx={.color=22
source=../hist.w
set=rx={.color=23
source=../dst.w
hist sends on physical color 22, but dst receives on 23, src's color. In the correct build
dst's rx is 22. Each link must have one color at both ends, unless a color swap changes it on
the way (lesson 8).
Fault 2, count off by one. faults/2-off-by-one/hist.w declares the send range as
let b in [0, NB - 1);. A range is half-open, so it ends before NB − 1, and hist sends seven
counts. A C loop for (b = 0; b <= NB - 1; b++) becomes [0, NB). dst waits for the eighth
count forever. A received count that is short by one points at a loop bound on either side.
Fault 3, out-of-bounds write. faults/3-out-of-bounds/hist.w drops the bin width:
∀i h[a[i]] += 1;
Samples 8 to 15 index past the eight bins of h, and their counts go to the memory after it.
With fsanitize=bounds, hist stops in binning with every bin still 0:
PE step phase received cycles idle stalled cycles per phase
src 2 finished 40 21 0 start 3, sending 37
hist 2 binning 64/64 - - - start 13, receiving 35
dst 1 receiving 0/8 - - - start 6
No bin was counted, so the check failed on the first sample. miget image=test-tutorial/bench-sim.core map=test-tutorial/bench.map symbol=hist.a decimal
prints the samples; the first is 15, and bin 15 does not exist.
Exercise. Two 16-bit values travel in one wavelet when the output queue can pack them (lesson 4), so 64 samples arrive as 32 wavelets and 8 counts as 4.
Next
This is the last extension topic. Back to the tutorial index.
Source code
The Tungsten and Paint files of this example, as the toolset installs them under share/tutorial/ext-layouts-debugging/debugging/. Select a line number to link to that line.