gpl: parallelize the CPU density scatter in place - #11262
Conversation
The per-cell scatter in BinGrid::updateBinsGCellDensityArea is the dominant hotspot of global placement. The parallel branch added for the GPU path accumulates into flat float buffers, which makes the total depend on thread arrival order, so the CPU path was left serial to keep regression goldens bit-stable. The bin accumulators are not floats. instPlacedAreaUnscaled_ and fillerArea_ are int64_t and each addend is truncated by the implicit conversion before it is added, so the total is a sum over a fixed multiset of integers: associative and commutative, hence independent of thread order. Scattering in place with #pragma omp atomic on those fields is therefore bit-identical to the serial loop at any thread count, needs no flat buffers, and compiles to lock xadd rather than a float CAS loop. The multiply order matches the serial loop and gpl already builds with FP contraction disabled, so the per-addend float value is unchanged. Bin clear and the float density/overflow loop are left serial; the change is confined to the scatter. The flat-buffer path stays under ENABLE_GPU. No effect unless -threads > 1. Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
There was a problem hiding this comment.
Code Review
This pull request parallelizes the CPU path for updating bin grid cell density areas using OpenMP. It introduces atomic addition methods to safely update bin areas across multiple threads. The reviewer noted that using atomic operations even during single-threaded execution introduces unnecessary performance overhead due to atomic instructions. They suggested branching on the thread count to run a non-atomic loop when running serially, and using explicit casts to avoid compiler warnings.
Review feedback on the previous commit. The `if (parallel_threads > 1)` clause suppressed only the threading, so the atomic still executed at -threads 1: the default path paid an atomic where it used to do a plain add, contrary to what that commit claimed. Rather than branch into atomic and non-atomic loops, collapse to one implementation, BinGrid::scatterDensityAreaInPlace(), used at every thread count, and delete the serial duplicate. With two paths the existing tests all exercised the serial loop and nothing exercised what threaded runs take; now they all do. A single-threaded run pays an uncontended atomic in this hotspot as a result -- one code path chosen over a serial fast path. std::atomic_ref<int64_t> with memory_order_relaxed replaces #pragma omp atomic. The field stays a plain int64_t, so Bin remains trivially copyable and no other accessor changes, and the memory order states the claim: atomicity is required, ordering is not. The float-to-int64_t truncation is now explicit at the call sites, being the reason the sum is order-independent. New test mt_invariance01 runs core01 at set_thread_count 8 against core01's single-threaded golden, which is the full solver trace. The rest of the suite runs at -threads 1 and does not cover concurrency. Also drops a comment claiming the CPU path must stay serial for bit-stable goldens. Note BinGrid::setNumThreads() has no callers, so BinGrid::num_threads_ is always 1; nbc_ is the thread count the rest of gpl uses. Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
|
Good catch on the atomics, and it was worse than the comment suggests: the Rather than branch into an atomic and a non-atomic loop, I went the other way: Also changed:
On the float-to-int question specifically: the ordering argument does not rely Two things I noticed in this function and deliberately did not touch:
Still no performance figure in the PR, by choice -- the reasoning and a |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a multi-threaded, order-independent density scatter implementation (scatterDensityAreaInPlace) on the CPU path using integer accumulators to ensure bit-identical results across different thread counts. It also adds a new multi-threading invariance test. The reviewer feedback suggests replacing std::atomic_ref with #pragma omp atomic for better portability and C++20 independence. Additionally, it is recommended to template scatterDensityAreaInPlace on a bool kAtomic parameter to avoid atomic instruction overhead during single-threaded execution.
Sort placement only; the entry split the mbff_* block in both test lists. Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
|
Thanks — but I'm declining all five of these, because they optimise the one On templating
On reverting
What One thing in there that is worth taking seriously, though not for the reason |
|
One more argument against the templated No non-trivial design is placed on a single thread. That makes divergence between the serial and threaded scatter actively harmful,
So the diagnostic use case is not an argument for a serial fast path -- it is Which reframes the performance question too: the atomic on the serial path The |
|
The machine finally went idle, so this PR now has measured numbers instead of a
Placement is unchanged: The 42/17 split is the part worth reading. Same machine, same thread count, Also measured, since I had asserted it earlier without evidence: the Everything green: 16/16 checks including Mac-Build, which also settles the |
What
Parallelize the per-cell density scatter in
BinGrid::updateBinsGCellDensityAreaon the CPU path, in place, using
std::atomic_ref<int64_t>withmemory_order_relaxedon the existingint64_tbin accumulators. Bit-identicalto the previous serial loop at any thread count.
The CPU path is now a single scatter implementation,
BinGrid::scatterDensityAreaInPlace(), used at every thread count; the serialduplicate is deleted.
That is the point rather than a side effect.
-threads 1is a diagnosticconfiguration, not how any non-trivial design is placed -- ORFS passes
-threads $(NUM_CORES). If the serial and threaded cases ran different loops,debugging a placement problem at
-threads 1would mean debugging code thatproduction never executes, and a bug that reproduces serially would not
necessarily be the bug you have. With one path, the diagnostic run exercises
exactly the production scatter, and so do all 104 gpl tests.
Why the CPU path was serial, and why it does not have to be
The parallel branch added for the GPU path accumulates into flat
floatbuffers. The comment there states the consequence:
That is true of that implementation:
std::vector<float>accumulation makesthe total depend on thread arrival order.
But the bin accumulators are not floats.
instPlacedAreaUnscaled_andfillerArea_areint64_t, and each addend is truncated before it is added:So the total is a sum over a fixed multiset of integers: associative and
commutative, hence independent of the order threads reach it. Accumulating in
place on those fields is therefore order-independent by construction, and
needs no flat buffers.
std::atomic_refrather than#pragma omp atomicor an atomic member: thefield stays a plain
int64_t, soBinremains trivially copyable for itsstd::vectorand no other accessor changes, andmemory_order_relaxedputsthe actual claim in the source -- atomicity is required, ordering is not, and
the accumulators are read only after the parallel region's implicit barrier.
The per-addend float value is unchanged too. The scatter computes a chain of
multiplies,
(overlap * scale) * targetDensity; hoisting the conditionaltargetDensityfactor out preserves that left-to-right order, and with noaddition in the chain there is nothing for FP contraction to fuse either way.
Bin clear and the float density/overflow loop are left serial, so the change is
confined to the scatter. The flat-buffer path stays under
ENABLE_GPU.Performance
Measured on two in-tree gpl tests, so you can reproduce both from this repo
alone. 16 threads,
global_placementonly (DEF read/write excluded), baselineand patched interleaved, median of 5 runs per arm on an otherwise idle machine.
Placement is unchanged:
Final HPWLwas a single distinct value across all tenruns of each design.
macro01large01Run-to-run spread was 106,409..123,011 / 58,598..65,067 for
macro01and106,425..109,057 / 89,518..91,351 for
large01; the distributions do notoverlap in either case.
Read the gap between those two rows before taking the first one. Same
machine, same thread count, same patch: 42% on one design and 17% on the other.
The gain is a property of the design, not of the change:
-threadsdefaults to 1, and a defaultopenroadinvocation gets nospeedup at all -- identical output, no parallelism. Only multi-threaded runs
benefit; ORFS passes
-threads $(NUM_CORES).macro01at 12.3% utilization has a far larger bin grid relative to its cellcount than
large01at 65.9%, and the scatter is correspondingly more of itsruntime. A dense design lands nearer the 17% end, a small one in the noise.
magnitude larger than a standard cell's.
Cost on the diagnostic path, since routing every run through the atomic is not
free:
core01at-threads 1, median of 9 interleaved runs, is 30 msbaseline against 31 ms patched. That is the worst case available -- 294
instances over a 16x16 grid, so the bin array stays in L1 and the locked
read-modify-write has no cache-miss latency to hide behind. It matches the
arithmetic (~250k accumulations at ~20 cycles). I have no single-threaded
figure for the large designs -- the attempt was spoiled by an unrelated
workload, see the hygiene note in the recipe -- so I will only say that I expect
it to matter less there, since the scatter is then bound by misses on a
multi-megabyte bin array rather than by arithmetic. That is reasoning, not a
measurement.
For context on why the table is hedged rather than headlined: my earlier gpl
performance claims (#11073, #11072, #11086, #11084) were measured at a density
that flattered them, did not survive re-measurement, and are closed with
corrected numbers. Hence stock in-tree tests, medians over interleaved runs, and
the ranges printed above.
Reproduction recipe: measure it yourself
Three in-tree gpl tests span the relevant regimes:
macro01large01large02(
large03has no DEF checked in; it cannot run standalone.)1. Build both binaries -- once at this branch's head, once at
origin/master-- keeping eachopenroadaside asopenroad-patched/openroad-base.2. Time global placement only, excluding DEF read/write. For each test,
derive a driver from the test's own Tcl so the setup matches exactly:
Run from
src/gpl/test(the Tcl uses relative paths):3. Benchmark hygiene -- this is where I lost a day. An unrelated job on the
same box moved my baseline from 97 s to 175 s, and later from 122 s to 496 s, on
identical input.
tasksetpinning did not rescue it, because the foreign jobwas unpinned and floated onto the same cores. So:
for stray
openroadprocesses before and after every single run and discardany run that was not alone; "idle at launch" is what produced the 4x spread
above.
then all of the other, so drift cannot masquerade as an effect.
baseline arm's spread is comparable to the difference you are claiming, you
have measured the machine, not the patch.
Final HPWLmust be identical between arms. If it is not,something other than this patch is in play.
4. Vary the thread count (
-threads 1,2,4,8,16) onmacro01. Scaling withthread count is the signal that you are measuring this loop; a flat curve means
the scatter is not your bottleneck on that design.
Determinism evidence
In the test suite. New
mt_invariance01runscore01atset_thread_count 8and diffs againstcore01's own single-threaded golden.That golden is the full log, including the entire 237-iteration
overflow/HPWL/penalty trace, so it pins the whole trajectory rather than a final
number. Registered in both CMake and Bazel. This is the coverage that was
missing: the rest of the suite runs at the default
-threads 1.Full suite. 104/104 pass (
bazelisk test --nocache_test_results //src/gpl/...).At scale, by hand.
macro01,large01andlarge02each run four ways --baseline at 1 and 16 threads, patched at 1 and 16 threads -- with the four logs
compared byte for byte. Identical in every case, modulo the
ORD-0030thread-count banner. Final HPWL: 4665013.08, 5128184.78, 4483580.32.
Open questions
The
ENABLE_GPUbranch is now the only user of the flat-buffer path. I do notbuild that configuration, so I have left it untouched rather than claim the two
can be collapsed -- with #10831 adding a Kokkos/CUDA build that is not a
formality. If the integer-accumulator argument holds there too, the float
buffers and their per-iteration allocation can go away entirely, but that is for
someone who can run it.
Two things I noticed in this function and did not touch, but which you may
want to know about:
BinGrid::setNumThreads()has no callers, soBinGrid::num_threads_isalways 1 and the loops using it are silently serial.
sumOverflowArea_ += overflowAreaunderreduction(+:),where
sumOverflowArea_isint64_tandoverflowAreaisfloat. Thatconverts the running total to
floaton every add, so unlike the scatter itgenuinely is thread-order-dependent -- the scatter is safe precisely because
each addend is truncated to an integer before accumulation, which this is
not. It is latent only because the thread count above is pinned to 1. Anyone
repairing the dead
setNumThreads()would make global placementnondeterministic without touching a line of placement code.
Separately, #11123 touches nearby code in this file. Happy to rebase behind it,
or to hand this over, whichever is less disruptive.