Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Parallel pipeline measurements

Historical measurements; see Profiling the compiler for the profiling guide.

Parallel pipelines

scripts/bench_par_pipelines.py compares a serial pipeline, the equivalent pool.map/reduce_tree calls, and the same pipeline after .par(&pool) (B75 stage 4). Cases are map+collect, filter+count, sum over i64 lists, i64 ranges and f64 lists, and reduce. The cheap callback is x * 3 + 1; the expensive one runs 100 rounds of a linear congruential step. The pool column for filter+count maps the predicate to 0/1 and sums serially; for the sums and reduce it is reduce_tree over pool.map. Run:

taskset -c 0,2,4,6,8,10,12,14 python3 -B scripts/bench_par_pipelines.py target/release/plenty

The taskset mask keeps the eight-worker pool on the eight physical cores of this laptop; more than ten busy threads drop its clock to about 1.1 GHz. Each program builds its input outside the timed region, times one terminal with the monotonic clock per repetition, discards the first repetition and prints a checksum; every variant of a case must agree. The script interleaves the variants over three rounds of four timed repetitions and reports medians in milliseconds. The machine was not otherwise idle (load average 2 to 5 from other work), so differences under about 10% are noise.

Screening. Before timing, --dump asm of the chunk workers showed the same per-item calls as the serial loop (plenty_list_scalar_get and, when collecting, plenty_collection for the append) for map, filter and count. The signed-sum worker folded into a seven-field tuple and called signed_step per item, with two memmove calls for the tuple state; a serial microbenchmark of that fold over ten million items took 400 ms against 20 ms for the same arithmetic in local variables and 6 ms for sum. The worker now runs the chunk in a loop over locals, tracking the exact running total as a wrapping i64 plus a wrap count. Combining collected chunks now appends each chunk’s storage with extend instead of moving items one at a time.

The first round of measurements found two losses: splitting a list moved each item on the calling thread with a list append (about 37 ns per item), and .par reduce collected every item, then ran reduce_tree, which submitted one job per pair. Three changes followed:

  • A list source was split by an internal list operation that copied each chunk’s contiguous rows in one bulk copy. Issue #455 replaced this with borrowed chunks, described in this report.
  • pool.reduce_tree gives one job an aligned run of adjacent inputs, a power of two chosen so there is about one run per worker, and the job reduces it serially through the same rounds. The remaining run results are combined by pair jobs. Because each round maps index i to i / 2, an aligned run is exactly a subtree of the fixed tree, so the grouping is unchanged; a test compares it with a serial level-by-level evaluation for lengths 0 to 70 and larger odd and even lengths, with 1, 2, 3 and 8 workers.
  • .par reduce now groups like sum: each chunk reduces its items left to right in its worker, and the chunk results are reduced in input order. No list of all items is collected. The worker’s loop makes the same direct calls per item as the serial pipeline (plenty_list_scalar_get, the callback and f).

Borrowed chunks (issue #455). A list chunk is now a span of the source’s rows: the job receives the first row’s address, the row size and the item count, and reads (or, for owned affine items, takes) items in place. The calling thread copies nothing; it allocates one small list of chunk bounds. This relies on every terminal draining its jobs before it returns. --dump asm of the filter+count worker showed the per-item loop with no calls (an imul and a load per row, then the inlined predicate); before, it called plenty_list_scalar_get per item, and the split called the runtime copy.

The first measurement after that change regressed filter+count and the sums at 100,000 items (0.46 to 1.24 times serial): the owned source was now dropped on the calling thread, and dropping a list walked and cleared every row even when the items own nothing (about 3.4 ns per row). A list of items that own no heap storage now frees its buffer without the walk. That also makes the serial pipelines, which consume the list, faster.

Results on an AMD Ryzen 7 7840HS, eight workers, with borrowed chunks and the drop change (load average 5 to 6 from other work):

CaseCallbackItemsSerialpoolparpar/serialpar/pool
map+collectcheap1,0000.0411.0820.0421.030.04
map+collectcheap100,0005.21388.0153.3130.640.04
map+collectcheap10,000,000567.2599362.107328.2010.580.04
map+collectheavy1,0000.3401.1420.1290.380.11
map+collectheavy100,00034.43397.3239.0940.260.09
map+collectheavy1,000,000331.989982.12074.5920.220.08
filter+countcheap1,0000.0061.0350.0132.190.01
filter+countcheap100,0000.637104.2340.0820.130.00
filter+countcheap10,000,00084.4309071.53031.8550.380.00
filter+countheavy1,0000.2710.7170.0800.300.11
filter+countheavy100,00027.91990.1433.9020.140.04
filter+countheavy1,000,000274.661883.82534.6120.130.04
sum i64cheap1,0000.0050.8090.0122.250.01
sum i64cheap100,0000.54489.3180.0890.160.00
sum i64cheap10,000,00080.11610037.63436.0100.450.00
sum i64heavy1,0000.2820.8120.0890.320.11
sum i64heavy100,00028.724108.0876.4200.220.06
sum i64heavy1,000,000282.463969.47040.3240.140.04
sum i64 rangecheap1,0000.0010.8100.01623.210.02
sum i64 rangecheap100,0000.06890.4650.0791.160.00
sum i64 rangecheap10,000,0006.5859071.5215.7210.870.00
sum i64 rangeheavy1,0000.3080.8920.1000.330.11
sum i64 rangeheavy100,00027.91991.4614.8820.170.05
sum i64 rangeheavy1,000,000276.006929.20138.8520.140.04
sum f64cheap1,0000.0050.8360.0101.820.01
sum f64cheap100,0000.54795.4490.0460.080.00
sum f64cheap10,000,00075.0719511.75430.2470.400.00
sum f64heavy1,0000.1030.8270.0340.330.04
sum f64heavy100,00010.58392.9451.6730.160.02
sum f64heavy1,000,000104.262919.34213.3210.130.01
reducecheap1,0000.0050.8500.0112.090.01
reducecheap100,0000.59690.5440.0640.110.00
reducecheap10,000,00076.8979112.80929.4580.380.00
reduceheavy1,0000.2700.7720.0760.280.10
reduceheavy100,00027.30788.8553.8670.140.04
reduceheavy1,000,000272.071897.81234.3520.130.04

Before, at main 9e3970d5 with the bulk-copy split, measured just before in the same conditions (load average 4 to 6):

CaseCallbackItemsSerialpoolparpar/serialpar/pool
map+collectcheap1,0000.0510.7630.0440.860.06
map+collectcheap100,0006.01698.5222.8380.470.03
map+collectcheap10,000,000641.97211471.189540.4340.840.05
map+collectheavy1,0000.3480.6680.1370.390.20
map+collectheavy100,00035.87398.5386.9670.190.07
map+collectheavy1,000,000358.0551089.04871.7630.200.07
filter+countcheap1,0000.0100.8120.0262.620.03
filter+countcheap100,0001.115134.4470.7380.660.01
filter+countcheap10,000,000136.06915679.961262.1021.930.02
filter+countheavy1,0000.3221.1820.1050.330.09
filter+countheavy100,00031.387145.9788.4570.270.06
filter+countheavy1,000,000312.9111371.32563.9290.200.05
sum i64cheap1,0000.0100.9210.0333.310.04
sum i64cheap100,0000.980132.0050.8210.840.01
sum i64cheap10,000,000129.79912216.945248.0141.910.02
sum i64heavy1,0000.3180.7760.0900.280.12
sum i64heavy100,00029.457114.1886.8680.230.06
sum i64heavy1,000,000317.6421362.14869.6370.220.05
sum i64 rangecheap1,0000.0022.1400.05427.750.03
sum i64 rangecheap100,0000.169156.2960.1640.970.00
sum i64 rangecheap10,000,0009.37911315.1015.7530.610.00
sum i64 rangeheavy1,0000.2650.8100.0830.310.10
sum i64 rangeheavy100,00026.57392.5175.2450.200.06
sum i64 rangeheavy1,000,000268.563895.40937.1410.140.04
sum f64cheap1,0000.0090.7690.0171.890.02
sum f64cheap100,0000.92292.6120.4660.510.01
sum f64cheap10,000,000113.0429285.398208.3121.840.02
sum f64heavy1,0000.1210.8920.0470.390.05
sum f64heavy100,00011.289100.9613.1310.280.03
sum f64heavy1,000,000114.782950.89419.8750.170.02
reducecheap1,0000.0090.7770.0212.240.03
reducecheap100,0001.021105.9880.6940.680.01
reducecheap10,000,000119.9679456.671202.3081.690.02
reduceheavy1,0000.3130.8340.0830.270.10
reduceheavy100,00028.51591.6814.2810.150.05
reduceheavy1,000,000279.319911.11639.3720.140.04

Findings.

  • With cheap callbacks over ten million list items, .par now takes 0.38 to 0.58 times the serial time (1.7 to 1.9 before). At 100,000 items it takes 0.08 to 0.16 times serial for filter+count, the sums and reduce, and 0.64 for map+collect, which still appends each result.
  • With expensive callbacks, .par runs at 0.13 to 0.26 times the serial time from 100,000 items, as before.
  • At 1,000 cheap items the job overhead still exceeds the work (1.8 to 2.3 times serial). A range source over cheap items is unchanged.
  • reduce keeps per-chunk grouping; pool.reduce_tree keeps aligned runs.

Remaining work. Early stop for parallel any/all; user-facing scoped pool jobs (a deferred B22 extension) could reuse the drain-before-return guarantee.