Level 5 — MasteryParallelism & Multicore

Parallelism & Multicore

90 minutes60 marksprintable — key stays hidden on paper

Chapter: 6.1 Parallelism & Multicore Difficulty: Level 5 — Mastery (cross-domain: math + architecture + coding) Time limit: 90 minutes Total marks: 60

Instructions: Answer all three questions. Show all derivations. Code may be written in C-like pseudocode or C11. State every assumption. Marks are shown per part.


Question 1 — Scaling laws, coherence traffic, and the real speedup ceiling (24 marks)

A dense linear-algebra kernel runs on a shared-memory NUMA machine. Profiling on 1 core shows:

  • Serial (non-parallelizable) fraction of the original work: s=0.08s = 0.08.
  • The parallel region performs N=109N = 10^9 floating-point operations.
  • Each core sustains 2.0×1092.0 \times 10^9 FLOP/s in the parallel region.

(a) Using Amdahl's Law, derive the general speedup SA(p)S_A(p) and compute the maximum achievable speedup as pp \to \infty. Then compute SA(16)S_A(16). (5)

(b) The team argues Amdahl is too pessimistic because they will grow the problem. Under Gustafson's Law with the same serial fraction s=0.08s = 0.08 (measured on the parallel run), derive the scaled speedup SG(p)S_G(p) and evaluate SG(16)S_G(16). Explain in one sentence the modelling assumption that makes SG>SAS_G > S_A. (5)

(c) Reality intrudes: every core, once per parallel iteration, updates a shared counter guarded by a cache line that must be transferred between cores. On this directory-based coherence system a remote cache-line acquisition costs tc=120nst_c = 120\,\text{ns}, and there are M=4096M = 4096 such synchronized iterations. Model the total runtime on pp cores as

T(p)=NpR(1s)+Tserial+M(p1)tcT(p) = \frac{N}{p \cdot R} \cdot (1-s') + T_{\text{serial}} + M \cdot (p-1) \cdot t_c

where R=2.0×109R = 2.0\times10^9 FLOP/s, ss' contributes a fixed serial time Tserial=sN/RT_{\text{serial}} = s'\cdot N/R with s=0.08s'=0.08, and the last term models coherence-traffic contention growing linearly in pp. Find the value of pp that minimizes T(p)T(p) (treat pp as continuous, then round), and state the minimum runtime. (9)

(d) The shared counter causes false sharing in an unrelated per-core statistics array laid out adjacently. Explain the mechanism, and give a concrete code-level fix (padding/alignment) with the cache-line size assumed. State how your fix changes the M(p1)tcM\cdot(p-1)\cdot t_c term qualitatively. (5)


Question 2 — Correct lock-free synchronization with CAS (20 marks)

You must implement a lock-free bounded increment: multiple threads increment a shared 64-bit counter but it must never exceed a cap CAP.

(a) Write a bool try_bounded_inc(atomic_uint64 *ctr, uint64 CAP) using compare-and-swap (CAS(addr, expected, desired) returns true on success). It must return false when at the cap, and be correct under contention. (7)

(b) Prove your loop is lock-free (system-wide progress) but not necessarily wait-free (per-thread bound). Give a concrete interleaving of 2 threads where one thread's CAS fails an unbounded number of times in principle. (6)

(c) Explain the ABA problem in the general CAS setting and argue precisely why the bounded-increment counter above is immune to ABA, even though it uses raw CAS. (4)

(d) Contrast this fine-grained atomic approach with a barrier-based bulk-synchronous alternative for the same aggregate work. State one workload characteristic that favors each. (3)


Question 3 — SIMD vectorization and heterogeneous offload (16 marks)

Consider the SAXPY operation yiaxi+yiy_i \leftarrow a\cdot x_i + y_i for i=0,,n1i = 0,\dots,n-1, single precision, n=4096n = 4096.

(a) Using AVX (256-bit registers, 8 fp32 lanes) with a fused multiply-add, compute the number of vector iterations, and the ideal arithmetic speedup vs scalar assuming one FMA per lane per cycle in both cases. State which of Flynn's classes AVX belongs to and why. (5)

(b) The array length is n=4100n = 4100 (not a multiple of 8). Write pseudocode showing the vectorized main loop plus scalar remainder (tail) loop, and state exactly how many vector iterations and scalar iterations run. (5)

(c) You offload SAXPY to a manycore GPU (heterogeneous computing). The data transfer over PCIe costs Tmove=50μsT_{\text{move}} = 50\,\mu s each way, and the GPU computes the kernel in Tgpu=3μsT_{\text{gpu}} = 3\,\mu s; the CPU (AVX) does it in Tcpu=40μsT_{\text{cpu}} = 40\,\mu s. Compute the effective GPU time including transfers and decide whether offload is worthwhile for this single call. Then state the condition (in terms of number of back-to-back calls kk reusing resident data) under which offload wins. (6)

Answer keyMark scheme & solutions

Question 1

(a) Amdahl's Law: with serial fraction ss, parallel fraction 1s1-s perfectly split over pp: SA(p)=1s+1sp.S_A(p)=\frac{1}{s+\frac{1-s}{p}}. (2 marks derivation)

As pp\to\infty: SA1/s=1/0.08=12.5S_A \to 1/s = 1/0.08 = 12.5. (1 mark)

SA(16)=10.08+0.9216=10.08+0.0575=10.1375=7.2727S_A(16)=\dfrac{1}{0.08+\frac{0.92}{16}}=\dfrac{1}{0.08+0.0575}=\dfrac{1}{0.1375}=7.2727. (2 marks)

(b) Gustafson: total scaled work per processor is s+(1s)ps+(1-s)p, ideal serial time s+(1s)ps+(1-s)p, parallel time 11: SG(p)=s+(1s)p=ps(p1).S_G(p)=s+(1-s)p = p-s(p-1). (3 marks)

SG(16)=0.08+0.9216=0.08+14.72=14.8S_G(16)=0.08+0.92\cdot16=0.08+14.72=14.8. (1 mark)

Modelling assumption: Gustafson fixes time and lets problem size grow with pp, so the serial part stays constant while parallel work scales up — the serial fraction shrinks relative to total work, avoiding Amdahl's fixed ceiling. (1 mark)

(c) With s=0.08s'=0.08, R=2×109R=2\times10^9:

  • Parallel time term: NpR(1s)=1090.922×109p=0.46p\frac{N}{pR}(1-s') = \frac{10^9\cdot0.92}{2\times10^9\,p}=\frac{0.46}{p} s.
  • Tserial=sN/R=0.08109/2×109=0.04T_{\text{serial}}=s'N/R=0.08\cdot10^9/2\times10^9=0.04 s (constant).
  • Coherence: M(p1)tc=4096(p1)(120×109)=4.9152×104(p1)M(p-1)t_c = 4096(p-1)(120\times10^{-9}) = 4.9152\times10^{-4}(p-1) s.

T(p)=0.46p+0.04+4.9152×104(p1).T(p)=\frac{0.46}{p}+0.04+4.9152\times10^{-4}(p-1). (3 marks setup)

Differentiate, ignoring constant terms: T(p)=0.46p2+4.9152×104=0p2=0.464.9152×104=935.96.T'(p)=-\frac{0.46}{p^2}+4.9152\times10^{-4}=0 \Rightarrow p^2=\frac{0.46}{4.9152\times10^{-4}}=935.96. p\*=935.96=30.59.p^\*=\sqrt{935.96}=30.59. (3 marks)

Round to p=31p=31 (check neighbours). T(31)=0.46/31+0.04+4.9152×10430=0.014839+0.04+0.014746=0.069585T(31)=0.46/31+0.04+4.9152\times10^{-4}\cdot30 = 0.014839+0.04+0.014746=0.069585 s. T(30)=0.015333+0.04+0.014255=0.069588T(30)=0.015333+0.04+0.014255=0.069588 s; T(31)T(31) slightly lower. Optimum p31p\approx31, Tmin0.0696T_{\min}\approx0.0696 s (≈69.6 ms). (3 marks; accept p=30 or 31 with justification)

(d) False sharing mechanism (2): the per-core statistics array elements sit in the same cache line as the shared counter (or adjacent entries share a line). Even though cores write logically-independent variables, the coherence protocol invalidates the whole line on every write, forcing ping-pong transfers — no true data dependency, pure layout artifact.

Fix (2): pad/align each per-core entry to a full cache line (assume 64 bytes):

struct alignas(64) PerCore { uint64_t stat; char pad[64-sizeof(uint64_t)]; };
PerCore stats[NCORES];  // each in its own line

Effect (1): eliminating false sharing removes the spurious invalidations, so the M(p1)tcM(p-1)t_c contention term shrinks toward only the genuinely shared counter's traffic — it does not vanish (the real counter still bounces) but the multiplier on coherence misses drops.

Question 2

(a) (7 marks: correct CAS loop, cap check, return values, atomicity)

bool try_bounded_inc(atomic_uint64 *ctr, uint64 CAP) {
    uint64 cur = atomic_load(ctr);
    for (;;) {
        if (cur >= CAP) return false;         // at cap, refuse
        uint64 next = cur + 1;
        if (CAS(ctr, &cur, next)) return true; // success
        // CAS failed: 'cur' is refreshed to actual value, retry
    }
}

Key points: reload-on-fail (CAS updates cur), cap re-checked each iteration, no lock.

(b) (6 marks) Lock-free: whenever any thread's CAS fails, it failed because another thread's CAS succeeded — so the system as a whole made progress (the counter advanced). Hence at least one thread always completes; no global stall. (3)

Not wait-free: interleaving — Thread A loads cur=5, then before A's CAS, Thread B successfully increments to 6, then Thread C to 7, ... A's CAS keeps failing. In principle other threads can succeed every time just before A, so A retries unboundedly; there is no per-thread step bound. (3)

(c) (4 marks) ABA: a location changes A→B→A between a thread's read and its CAS; the CAS wrongly succeeds because it only sees value equality, missing the intermediate change — dangerous when the value is a pointer/tag whose meaning changed. (2) Immunity here: the counter is monotonically non-decreasing up to CAP; it never revisits a prior value, so no A→B→A sequence can occur. Value equality therefore does imply "unchanged since read." Raw CAS is safe. (2)

(d) (3 marks) Fine-grained CAS: low latency, good when increments are sparse/irregular and threads proceed independently. Barrier BSP: threads compute local partial sums, then one synchronized reduction — better when work is regular, bulk, and per-thread contention on the shared counter would otherwise dominate (avoids the ping-pong of (b)). Favor CAS for irregular/low-contention; favor barrier+reduction for dense/high-contention bulk workloads.

Question 3

(a) (5 marks) Vector iterations =4096/8=512= \lceil 4096/8\rceil = 512. (1) Ideal speedup with 8 lanes and one FMA/lane/cycle vs scalar one FMA/cycle: . (2) AVX is SIMD (Single Instruction, Multiple Data): one instruction applies the same FMA op across 8 data lanes simultaneously. (2)

(b) (5 marks) For n=4100n=4100: vector iterations =4100/8=512=\lfloor 4100/8\rfloor = 512 (covers indices 0..4095); scalar tail =41004096=4= 4100-4096 = 4 iterations. (2 for counts)

size_t i = 0;
size_t vend = n - (n % 8);          // 4096
for (; i < vend; i += 8) {          // 512 vector iters
    __m256 vx = _mm256_loadu_ps(&x[i]);
    __m256 vy = _mm256_loadu_ps(&y[i]);
    vy = _mm256_fmadd_ps(_mm256_set1_ps(a), vx, vy);
    _mm256_storeu_ps(&y[i], vy);
}
for (; i < n; ++i)                  // 4 scalar tail iters
    y[i] = a*x[i] + y[i];

(3 for correct main+tail structure)

(c) (6 marks) Effective single-call GPU time =Tmove(in)+Tgpu+Tmove(out)=50+3+50=103μs= T_{\text{move}}(\text{in}) + T_{\text{gpu}} + T_{\text{move}}(\text{out}) = 50+3+50 = 103\,\mu s. (2) CPU AVX =40μs<103μs=40\,\mu s < 103\,\mu soffload NOT worthwhile for a single call. (2) For kk back-to-back calls reusing resident data (transfer once, or amortized): GPU =250+k3=100+3k=2\cdot50 + k\cdot3 = 100+3k; CPU =40k=40k. Offload wins when 100+3k<40k100<37kk>2.70100+3k < 40k \Rightarrow 100 < 37k \Rightarrow k > 2.70, i.e. k3k \ge 3 calls. (2)

[
  {"claim":"Amdahl S(16) with s=0.08 equals 80/11 approx 7.2727","code":"s=Rational(8,100); SA=1/(s+(1-s)/16); result=(SA==Rational(80,11)) and abs(float(SA)-7.27272727)<1e-6"},
  {"claim":"Gustafson S(16)=14.8","code":"s=Rational(8,100); SG=s+(1-s)*16; result=(SG==Rational(74,5)) and float(SG)==14.8"},
  {"claim":"Optimal continuous p for T(p) is sqrt(0.46/4.9152e-4) approx 30.59","code":"popt=sqrt(Rational(46,100)/Rational(49152,100000000)); result=abs(float(popt)-30.594)<0.01"},
  {"claim":"GPU offload breakeven: k>=3 calls (100+3k<40k)","code":"k=symbols('k'); sol=solve(100+3*k-40*k,k)[0]; result=(abs(float(sol)-Rational(100,37))<1e-9) and (3>float(sol)) and (2<float(sol))"},
  {"claim":"AVX vector iters for n=4100 is 512 and scalar tail is 4","code":"n=4100; result=(n//8==512) and (n%8==4)"}
]