English
Data decomposition and integration
Vector decomposition
A vector of
Figure 8.7. Distributions of a vector among threads
- Block: thread
gets a contiguous range of indices (integer division). The owner of element is approximately . Memory access is sequential, and each thread works with its own cache lines. This is the typical distribution ofParallel.Forwith ranges and of MPI programs. - Cyclic: thread
gets elements , that is, the owner is . It balances the load well when the “weight” of the elements changes smoothly along the vector (for example, working with a row of a triangular matrix), but neighboring elements belong to different threads: in shared memory each thread reads all cache lines, and SIMD is impossible in vector code. - Block-cyclic with block size
: blocks of elements are dealt out in a round-robin fashion, and the owner is . It combines the advantages of both: access is sequential within a block, and the load is balanced across the whole vector. This is how the ScaLAPACK library distributes matrices.
For the dot product Math.Max operation. In the “Dot product and vector distributions” example (vectors of 20 million double values), the block and block-cyclic distributions reached a speedup of 2.5 already on 4 threads, after which it stopped growing: 320 MB of data are read at about 45 GB/s, and memory bandwidth becomes the limit, as in Topic 7. On 8 and 16 threads, the cyclic distribution turned out to be slower than the sequential loop: each thread uses only one of the eight double numbers in each cache line, so memory and the cache have to transfer
Nondeterminism of floating-point sums
Floating-point addition is not associative:
- a result that depends on
but is the same between runs (a fixed distribution and adding the partial sums in the order of the thread numbers) is reproducible: it can be debugged and compared; - a result that depends on the order in which threads finish (partial sums added under a
lockinlocalFinally,Interlockedaddition of floating-point numbers viaCompareExchange) changes from run to run even on the same computer.
In the “Dot product and vector distributions” example, the block distribution on 4 threads gives −111.15502539965507 every time, while Parallel.For with local sums and a lock gave five different values in five runs, differing in the 13th significant digit. For reproducible results:
- divide the data into a fixed number of parts that does not depend on the number of threads (64 parts in the conjugate gradient method of the lab) and add the partial sums in the order of the part numbers: then the result is the same for any
; - split a recursive sum in half by indices (as in the
Sumfunction above) rather than by execution order: the addition tree then depends only on the data; - compare the parallel and sequential results with a relative tolerance rather than the
==operator; - for very long sums, use Kahan compensated summation (Kahan summation), which reduces the rounding error.
Matrix decomposition
An
- horizontal stripes (row-wise): a thread gets
contiguous rows; - vertical stripes (column-wise): a thread gets
contiguous columns; - checkerboard (checkerboard, 2D block):
threads form a grid, and thread gets a block of rows and columns.
Figure 8.8. Matrix decomposition schemes
Matrix–vector multiplication
For
Table 8.4. Matrix–vector multiplication with the three schemes
| Scheme | Computation per thread | Communication (in distributed memory) |
|---|---|---|
| horizontal stripes | each process needs the whole vector | |
| vertical stripes | a partial vector | a reduction of the partial vectors: |
| checkerboard | a partial vector of length | broadcasting part of |
The amount of computation is the same:
The “Matrix–vector multiplication” example measures all three schemes for two matrix sizes. For an
Matrix multiplication
The product
The striped algorithm. Process
Fox’s algorithm (1987). The processes form a
- process
, where , broadcasts its block along row of the grid; - each process of the row multiplies the received block by its current block of
and adds the result to ; - the blocks of
are cyclically shifted up one position within the column.
Cannon’s algorithm (1969) replaces the broadcast with cyclic shifts (Fig. 8.9). First, an alignment (skew) is performed: row
Figure 8.9. Cannon’s algorithm on a
Table 8.5. Computation and communication of matrix multiplication algorithms
| Algorithm | Computation | Communication per process | Memory |
|---|---|---|---|
| striped | |||
| Fox | |||
| Cannon |
The communication volume of Fox’s and Cannon’s algorithms is Barrier. In shared memory there is no communication advantage, and the stripes turned out to be the fastest: all threads read the same matrix
Parallel numerical integration
A definite integral
- the rectangle (midpoint) rule:
, error ; - the trapezoidal rule:
, error ; - Simpson’s rule (
even): , error .
Each formula is a weighted sum of values of MPI_Reduce, Topic 12) – exchanging just one number.
Runge’s rule estimates the error without the exact value. If a formula has order
The computation is repeated, doubling
cs
static double Simpson(Func<double, double> f, double a, double b,
int n) // n is even
{
double h = (b - a) / n;
double sum = 0;
object gate = new();
var ranges = Partitioner.Create(1, n, 100_000);
Parallel.ForEach(ranges, () => 0.0, (range, _, local) =>
{
for (int i = range.Item1; i < range.Item2; i++)
local += (i % 2 == 1 ? 4 : 2) * f(a + i * h);
return local;
}, local => { lock (gate) sum += local; });
return h / 3 * (f(a) + sum + f(b));
}
double i1 = Simpson(Math.Sin, 0, Math.PI, 100);
double i2 = Simpson(Math.Sin, 0, Math.PI, 200);
double runge = (i2 - i1) / 15; // estimate of I − I(2n)
Console.WriteLine($"I(n) = {i1:F12}, I(2n) = {i2:F12}");
Console.WriteLine($"Runge: {runge:E2}, true error " +
$"{2 - i2:E2}");
Console.WriteLine($"Refined value: {i2 + runge:F12}");For
I(n) = 2,000000010825, I(2n) = 2,000000000676
Runge: -6,77E-010, true error -6,76E-010
Refined value: 2,000000000000Adaptive integration
A uniform grid spends equally much computation both where the function is almost constant and where it changes rapidly. Adaptive quadrature halves a segment only where the error estimate is large (Fig. 8.10). For Simpson’s rule on a segment
Figure 8.10. Adaptive integration: partitioning and the recursion tree
The adaptive algorithm is a divide-and-conquer recursion in which the amount of work in different parts is unknown in advance. A static distribution of the segment into Parallel.Invoke tasks, and free pool threads take them by work stealing. To keep the tasks from being too small, a threshold is introduced: tasks are created up to a certain recursion depth, and beyond it ordinary sequential recursion is used. With a threshold of
Multiple integrals and the Monte Carlo method
A multiple integral Parallel.For, and the inner loop is an ordinary sum by the formula in one direction, as a two-dimensional decomposition. The number of nodes grows as
The Monte Carlo method estimates an integral by the mean value of the function at random points: Random is not thread-safe, and for reproducibility the blocks are fixed (Topic 6). For example, the volume of a ball of radius 1 as the fraction of 64 million random points of the cube
cs
const int Blocks = 64, PerBlock = 1_000_000;
long[] hits = new long[Blocks];
Parallel.For(0, Blocks, k =>
{
Random random = new(2026 + k); // a generator per block
long inside = 0;
for (int i = 0; i < PerBlock; i++)
{
double x = random.NextDouble() * 2 - 1;
double y = random.NextDouble() * 2 - 1;
double z = random.NextDouble() * 2 - 1;
if (x * x + y * y + z * z <= 1) inside++;
}
hits[k] = inside;
});
double share = (double)hits.Sum() / ((long)Blocks * PerBlock);
double volume = 8 * share;
double sigma = 8 * Math.Sqrt(share * (1 - share)
/ ((long)Blocks * PerBlock));
Console.WriteLine($"V = {volume:F5} ± {1.96 * sigma:F5} " +
$"(exact {4 * Math.PI / 3:F5})");The result contains the 95% confidence interval
V = 4,18884 ± 0,00098 (exact 4,18879)