Distributed code performance

Hello,

I am playing with the code from Engin's ChapelCon'25 "Performance Debugging" presentation.

git clone https://github.com/chapel-lang/chapelcon25-demos.git demos
cd demos/PerformanceDebugging

I reran the examples (no code modification) on a cluster. Here are my results for two single-core runs:

load single-locale Chapel 2.10.0 built with CHPL_COMM=none CHPL_TARGET_CPU=native CHPL_TASKS=qthreads CHPL_TARGET_MEM=jemalloc
cd 01-parallel
chpl --fast CoolStencil.chpl
salloc --time=1:0:0 --mem-per-cpu=14400 --nodes=1 --cpus-per-task=1
./CoolStencil --n=20_000
0.376GB/s 1.431GB/s 0.547GB/s 1.625GB/s 0.518GB/s
 # each number is a separate run on the same allocated node -- quite a bit of variation
load multi-locale Chapel 2.10.0 built with CHPL_COMM=gasnet CHPL_COMM_SUBSTRATE=mpi CHPL_GASNET_SEGMENT=everything CHPL_TASKS=qthreads CHPL_TARGET_CPU=native CHPL_TARGET_MEM=jemalloc
cd ../04-scalable
chpl --fast CoolStencil.chpl
salloc --time=1:0:0 --mem-per-cpu=14400 --nodes=1 --cpus-per-task=1
./CoolStencil -nl 1 --n=20_000
0.048GB/s 0.049GB/s 0.048GB/s
 # this one is a lot more consistent

On the same one CPU core, going to the BlockDist/scalable version of the code gives ~15-20X worse performance. There is no communication: it's a single node, single core, so no actual data decomposition and just a single thread. Why do I see such a drop in performance? Am I doing something wrong?

On another (InfiniBand) cluster with CHPL_COMM=gasnet CHPL_COMM_SUBSTRATE=ibv CHPL_LAUNCHER=gasnetrun_ibv I see ~12X drop in performance for the same comparison.

Adding more cores shows a nice performance increase as expected, but that's on top of already degraded performance (comparing to the original single-core run, or to a similar C+MPI code).

Thank you,

--
Alex Razoumov
Simon Fraser University

Hi Alex,

I haven't looked at the relevant code in a while. So, this is from memory, but I am relatively confident.

Under the hood, distributed array access code looks like:

proc access(idx) {
  if !build_for_multilocale {
    fast_access(idx);   // think local pointer offset as in C
  }
  else {
    if is_idx_local(idx) {  // some math
      fast_access(idx);  // similar fast index
    }
    else {
       slow_maybe_remote_access(idx);  // heavyweight, might do comm
    }
  }
}

Your first run uses a single-locale Chapel build as far as I understand. So, it goes through the very first branch. What's important about that branch is that the first condition build_for_multilocale is a compile-time constant and evaluates to true. IOW, the Chapel standard library and the runtime is "aware" that it is built for single locale. So, at compile-time that whole conditional and logic is folded away and you are left with fast_access only. In that scenario, I'd expect you to reach C-like performance.

When you are using multi locale Chapel, that condition evaluates to false at compile time and you are left with the big else branch. So, every access has to do some math to confirm that the data is local, before going through the same C path. IOW, you now have an executable that can be run on 1000s of nodes and cores. The information that you are running it on a single node/core is only available when you launch it. So, it still has to go through all the checks. I believe the hit you are seeing is coming from that.

Based on my experience, if you are running at scale, you will see some communication and the costs of those checks are not observable next to communication. If you are running an embarrassingly parallel application, like Stream, there might be some small hits from those checks, but there are also some optimizations in place to mitigate them.

Hope this helps,
Engin

1 Like

A 15-20x gap on a single core with no communication makes me suspect per-element overhead in the distributed array accesses rather than anything in the hardware. It might be worth checking whether the stencil loop in the BlockDist version is being optimized the same way, for instance by comparing a version that iterates over the local subdomain with one that indexes through the global domain. It could also help to rebuild both with the same --fast flags and run one of them with a profiler such as perf to see where the time goes.

One other thing I'd be curious about is how much work happens between the distributed array accesses.

If the stencil is doing very little arithmetic per element, those locality checks Engin mentioned could end up dominating the runtime, even with everything on one locale.

It might be interesting to keep the same array layout but increase the computation per element in a small benchmark. If the relative slowdown shrinks as the arithmetic gets heavier, that would give another clue about where the overhead is coming from.

It wouldn't fix the issue, of course, but it could help separate the access overhead from the actual stencil computation.

Thank you all for your feedback!

The slowdown is indeed caused by indexing into distributed arrays; it is the magnitude of the slowdown that is surprising.

To answer the last comment about adding computations per element: as far as you don't add to the distributed array access, e.g. in this code

// run a 5-point stencil
forall (i, j) in Data.domain.expand(-1) {
  var tmp = 0.0;
  for k in 1..50 do
    tmp += sin(0.01*i+k)/j;
  OutData[i, j] = (Data[i-1, j] +
                   Data[i, j-1] + Data[i  , j] + Data[i, j+1] +
                   Data[i+1, j]) / 5 + tmp;
}

both local and distributed versions of the code spend the majority of their time on computations, not array access, and the relative performance drop between the two version is below 2X, as opposed to 12-20X earlier. If instead I increment the distributed array OutData[i, j] += sin(0.01*i+k)/j; the performance drop is large again.

I started looking into this trying to compare the performance of a C+MPI stencil code vs. an equivalent Chapel code with BlockDist arrays. The difference is quite dramatic, with C+MPI being significantly faster even at scale. Moving from BlockDist to StencilDist does help a bit, but there is still quite a big discrepancy. Happy to share my codes, if there is interest.

Thank you,

Hi Alex —

I'd be happy to look through your codes as time permits if you can provide pointers.

Thanks,
-Brad

Hi Brad,

Thank you for your kind offer! I've added the code and some explanatory notes to GitHub - razoumov/performance · GitHub

Please let me know if anything is unclear.

Best,
Alex

Hi Alex —

At a glance, the use of the stencil distribution looks appropriate to me, and I would agree that the delta between the block and stencil distributions' performance seems surprisingly small. I hope to do some more digging, but thought I'd at least confirm that much for now before lunch and some other things I should be prioritizing.

One thing I wonder about is the copy-back using array slices within the loop–specifically whether that's bogging down the computation in some way, and whether unrolling that loop by two to avoid the copy-back or using a whole-array assignment might help. But that's a stab in the dark.

I'd also like to check on the communications being done to make sure they're as minimal as they should be.

-Brad

Hi Brad,

You are absolutely right: it is the copy-back with large array slices T[1..rows,1..cols] = Tnew[1..rows,1..cols]; that was slowing down the code!

Following your comment, I first tried T = Tnew; (without modifying the boundary, that gives a different solution for BlockDist, but that's easy to correct) and the timings went down to:

with BlockDist:

At 50000x50000 after 10 iterations reached delta=1.33821 in 0.539635 0.529417 seconds
At 100000x100000 after 10 iterations reached delta=1.33821 in 1.75888 seconds
At 200000x200000 after 10 iterations reached delta=1.33821 in 6.7203 seconds
(within 2X of C+MPI)

with StencilDist:

At 50000x50000 after 10 iterations reached delta=1.94288 in 2.48717 seconds
At 100000x100000 after 10 iterations reached delta=1.94296 in 9.52659 seconds
At 200000x200000 after 10 iterations reached delta=1.943 in 43.6516 seconds
(no real change)

Next I put T[i,j] = Tnew[i,j]; into a second `forall (i,j) in Dist` loop, and here is what I got:

with BlockDist:

At 50000x50000 after 10 iterations reached delta=1.94288 in 0.558629 seconds
At 100000x100000 after 10 iterations reached delta=1.94296 in 1.92567 seconds
At 200000x200000 after 10 iterations reached delta=1.943 in 7.34847 seconds

with StencilDist:

At 50000x50000 after 10 iterations reached delta=1.94288 in 0.322453 seconds
At 100000x100000 after 10 iterations reached delta=1.94296 in 1.1081 seconds
At 200000x200000 after 10 iterations reached delta=1.943 in 4.82358 seconds
(only ~30% slower than C+MPI)

Thank you so much, this is really fantastic!

Alex.

1 Like

@razoumov :

My subconscious must've still been chewing on this topic this morning, because in the wee hours, I remembered something else you should try: I believe that if, rather than assigning one array back to the other, you use Chapel's swap operator, <=>, to swap the two arrays, it should be implemented as pointer swaps rather than deep copies of the array since they share the same domain. This should get you similar performance as the unroll-by-two approach I suggested above, but without the code complexity (and potential icache hit) that approach would result in. If your MPI version happens to be using pointer swaps, it may also be more comparable in that regard.

If you get the chance to give it a try, let us know how it performs for you.

-Brad

Hi Brad,

I tried the pointer swap T <=> Tnew and -- of all approaches -- it indeed produces the fastest solution:

with BlockDist:

At 50000x50000 after 10 iterations reached delta=1.94288 in 0.419369 seconds
At 100000x100000 after 10 iterations reached delta=1.94296 in 1.41929 seconds
At 200000x200000 after 10 iterations reached delta=1.943 in 5.38291 seconds

with StencilDist:

At 50000x50000 after 10 iterations reached delta=5.48573 in 0.16537 seconds
At 100000x100000 after 10 iterations reached delta=5.48548 in 0.578898 seconds
At 200000x200000 after 10 iterations reached delta=5.48535 in 2.82762 seconds

With StencilDist, however, swapping pointers breaks StencilDist's internal communication layer, producing a wrong numerical solution. I tried running both

T.updateFluff();
Tnew.updateFluff();

but this does not solve the problem: I get a different numerical solution. Likely something to do with the internal tracking structures for halo exchanges and communication buffers inside StencilDist.

It seems with StencilDist the fastest correct solution is still to unroll the loop forall (i,j) in Dist do T[i,j] = Tnew[i,j].

Thank you,

Alex.

Hi Alex —

Huh… I would not have expected the swap operator to change the behavior relative to your version using whole-array assignments back. There could be a bug in its implementation, but just to clarify its behavior before assuming the worst:

The swap operator is not defined in the language as a pointer swap, but rather as a swap via assignments. So T = Tnew should be equivalent to const Temp = T; T = Tnew; Tnew = Temp; or forall (t1,t2) in zip(T, Tnew) { const temp = t1; t1 = t2; t2 = temp; }. However, for arrays like this, we should implement by swapping the underlying pointers to make it even faster.

So the first question would be whether your code is sound under that interpretation? Check my work, but I believe your global boundary condition would need to be applied to both arrays in order for it to be correct? (but I'd imagine that would also have been the case for the version you wrote using whole-array assignment?)

If it seems like it should work but isn't, please share the swap version of your code, and I'll take a look at it to make sure the swap is being implemented correctly.

Thanks,
-Brad

Hi Brad,

Still having problems, but only with delta parallel reduction with StencilDist and array swap.

I added three simplified codes s{1,2,3}.chpl to the repository GitHub - razoumov/performance · GitHub . They are identical except:

  • s1.chpl implements T = Tnew;
  • s2.chpl implements const Temp = T; T = Tnew; Tnew = Temp;
  • s3.chpl implements the array swap T <=> Tnew;

All of them use StencilDist and specify the boundary condition for both T and Tnew. I run the three codes inside a Slurm job allocating resources on three nodes (--nodes=3 --cpus-per-task=4) launching the codes with:

./s1 -nl $SLURM_NNODES --niter=5 --nout=1
./s2 -nl $SLURM_NNODES --niter=5 --nout=1
./s3 -nl $SLURM_NNODES --niter=5 --nout=1

Here is what I get:

Running on 3 nodes
Working with a matrix 650x650 to 5 iterations or dT below 0.002
Iteration 0: T=25
Iteration 1: delta=27.5 T=12.5
Iteration 2: delta=10.2971 T=9.375
Iteration 3: delta=6.85962 T=7.03125
Iteration 4: delta=4.71022 T=5.85938
Iteration 5: delta=4.28341 T=4.88281
Working with a matrix 650x650 to 5 iterations or dT below 0.002
Iteration 0: T=25
Iteration 1: delta=27.5 T=12.5
Iteration 2: delta=10.2971 T=9.375
Iteration 3: delta=6.85962 T=7.03125
Iteration 4: delta=4.71022 T=5.85938
Iteration 5: delta=4.28341 T=4.88281
Working with a matrix 650x650 to 5 iterations or dT below 0.002
Iteration 0: T=25
Iteration 1: delta=27.5 T=12.5
Iteration 2: delta=10.2971 T=9.375
Iteration 3: delta=6.85962 T=7.03125
Iteration 4: delta=7.86526 T=5.85938
Iteration 5: delta=8.04047 T=4.88281

Notice the wrong computation of delta in the last one. I am using parallel reduction

delta = max reduce abs(Tnew - T);

so it should work correctly in all cases. As far as I can see, both T and Tnew are computed correctly -- it is just the delta calculation that is off.

The same three codes perform correctly with BlockDist, and they perform correctly with StencilDist on one locale.

Thank you,

Alex.

Alex —

Interesting, thanks for sharing your code. That definitely looks like a bug to me. Interestingly, it seems to work better with some locale counts than others. -nl 2, 3, and 5 all produce bad results for me, but others work fine. Taking a deeper look.

-Brad

Hi Brad,

The problem also goes away if I use a constant boundary, e.g. T[i,cols+1] = 80.0; instead of a varying one T[i,cols+1] = 80.0*i/rows; so it seems that the bug might be related to indexing of ghost cells.

Thanks,

Alex.

Hi Alex —

I didn't see your response earlier, but I reached the same conclusion about a half hour after you, and I can now explain what's going wrong. Happily (for me, as the implementor of the swap), it's not a bug in the swap, but rather a subtle issue in the user code—though I'm wondering whether perhaps we could've done more to protect you. It really is a subtle case.

Consider the following lines, running on a 10x10 array mapped to 2 locales, such that locale 0 owns {1..5, 1..10} and locale 1 owns {6..10, 1..10}:

for i in 1..rows {
  T[i,cols+1] = 80.0*i/rows;   // right-side boundary
  Tnew[i,cols+1] = T[i,cols+1];
}

And keep in mind that with the fluff value of 1, each will actually own {0..6, 0..11} and {5..11, 0..11}, respectively.

Now, consider the iteration when i==5. Because this is a loop over a range (1..5) and ranges are not distributed, all iterations of the loop will be executed by the task that encountered the loop from the locale where it's executing. Since this loop doesn't appear within an on-clause, either explicitly or implicitly, it'll be executed on locale 0. I noticed that this would be a scalability problem as rows and cols get larger, but I didn't anticipate that it would also be a correctness problem (nor did I really think about these lines of code much at all, admittedly, since they seemed innocuous).

The problem is that the semantics of StencilDist are that if a value can be read locally, it will be. So what happens is that the read of T[i, cols+1] for i==5 is a read of T[6, 11], which falls within locale 0's fluff. Meanwhile, the previous write to T[i, cols+1] appropriately wrote to locale 1's copy of T[6, 11] since writes target the original copy of the array value; and, of course, we haven't called updateFluff() between the assignments (nor should we I think… feels like overkill). So the problem is that we're reading a default-initialized fluff value, getting the value 0.0 back, and storing that into Tnew[6, 11] on locale 1.

For the first iteration of the stencil, this isn't a problem since we're not reading Tnew anyway. For the versions that don't swap, it's also not a problem because we'll never read Tnew[6, 11]. However, for the version that swaps using the pointer-swap implementation, Tnew[6,11] becomes T[6,11] on the second iteration, and by the time we hit iteration 3, it starts resulting in errors propagating into the delta value. Specifically, if I print out the array on every iteration, I find that the rightmost computed column of values ceases to be monotonically increasing due to the 0.0 bringing its average down.

So, one solution would be to repeat the boundary condition expression in both assignments to avoid the read of the fluff, and this is why your constant boundary condition case also worked:

for i in 1..rows {
  T[i,cols+1] = 80.0*i/rows;   // right-side boundary
  Tnew[i,cols+1] = 80.0*i/rows;
}

But a better approach for the sake of scalability would be to use a forall loop over a distributed domain or array to assign the boundaries since this will not only use multiple tasks, but also causes each task to compute that locale's local sub-array of values. Here's my preferred way to express that:

forall (i,j) in Dist.exterior(0,1) {
  T[i,j] = 80.0*i/rows;   // right-side boundary                                
  Tnew[i,j] = T[i,j];
}
forall (i,j) in Dist.exterior(1,0) {
  T[i,j] = 80.0*j/cols;   // bottom-side boundary                               
  Tnew[i,j] = T[i,j];
}

Trying to think of things we could've done to help alert you to this issue, I've been coming up short so far. I'd be curious if you, or others reading, have ideas.

I will be curious what kind of performance you get from this version once correct. And that reminds me that I also wanted to mention that my expectation (without a high degree of confidence) is that the previous approach to the reduction in which you compute it alongside the stencil seems like it ought to be faster at large problem sizes since it only requires traversing the pair of arrays once rather than twice, resulting in better locality.

Hope this all makes sense, and please ask questions if it doesn't.
-Brad

Hi Brad,

Thank you for finding the mistake in my code and for the detailed explanation! This is a very subtle issue, yet unfortunately an easy one to introduce. Since the boundary conditions were outside the main timed loop, I naively assumed that setting them locally on node 0 would simply work, and never gave it a second thought.

I doubt that user errors like this can be prevented at the compiler level, and should probably be addressed through user education. Perhaps we could put together a simple StencilDist tutorial that highlights common pitfalls like this one. I'll think of a suitable example on my end.

The new version ran nicely at scale, giving the correct delta and solution. The timing is the same as in my previous runs with T <=> Tnew and StencilDist, ~24% better than C+MPI at 200,000^2 grid resolution on five nodes on our production cluster.

Yes, at scale I am using the delta reduction inside the same loop where I compute the stencil, it is faster than delta = max reduce abs(Tnew - T) by ~2-3X.

I am preparing a workshop on benchmarking at scale where I will probably cover a 3D version of the diffusion solver first in C+MPI, then in serial Chapel, will add multi-core processing on one node, then BlockDist and StencilDist versions, finally scaling everything to fill the memory on ~5 nodes. Based on the current 2D tests, things should work nicely in 3D, and 3D will be so more impressive to compare the lengths of the C and Chapel implementations. I'll try to come up with simple examples to demo the issues discussed in this thread.

Thank you again,

Alex.

Hi Alex —

Sounds good, best of luck with the workshop preparation. If you'd be
interested in writing an article summarizing some of your versions and
comparison for the Chapel blog, let us know. Stencils come up so
freuqently that I imagine you'd be creating something that people would
refer back to over time.

-Brad

Alex, the point about boundary conditions living outside the timed loop is the part that would trip up most people — it looks harmless precisely because it isn't where the performance work happens. A StencilDist tutorial built around that pitfall would be more useful than a compiler warning, since the fluff-region read semantics are the actual thing to internalize. The 3D workshop progression from C+MPI through BlockDist to StencilDist sounds like a compelling way to show the code-length gap. Brad's blog suggestion seems worth considering; a write-up with these examples would be something people return to.