Skip to content

web: multithread and optimize save_image rendering - #11239

Merged
maliberty merged 6 commits into
The-OpenROAD-Project:masterfrom
calewis:save-image-opt
Aug 26, 2026
Merged

web: multithread and optimize save_image rendering#11239
maliberty merged 6 commits into
The-OpenROAD-Project:masterfrom
calewis:save-image-opt

Conversation

@calewis

@calewis calewis commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Parallelize tile rendering and compositing in renderImageBuffer using utl::ThreadPool.
  • Parallelize nearest-neighbor image resampling with precomputed coordinate maps.
  • Implement 64-byte block copy, solid span filling, and transparency check (anyNonZero).
  • Use fast fixed-point arithmetic for Porter-Duff alpha compositing.
  • Add row-level transparency early-exit in Lanczos-2 decimation.
  • Thread count is propagated from WebServer to TileGenerator, respecting ord::set_thread_count.

SaveImageTest.LabelsFollowTheVisibilityFlag performance (4096x3589 px) vs baseline:

Configuration Time (ms) Speedup vs Baseline
Baseline (parent) 3445 1.00x
1 thread 323 10.67x
4 threads 116 29.70x
16 threads 58 59.40x

Type of Change

Performance improvement

Impact

Makes it run faster

Verification

  • I have verified that the local build succeeds (./etc/Build.sh).
  • I have run the relevant tests and they pass.
  • My code follows the repository's formatting guidelines.

I also attached two diffs I generated from the test images.

pr_comparison_rudy pr_comparison_multilayer

- Parallelize tile rendering and compositing in renderImageBuffer using utl::ThreadPool.
- Parallelize nearest-neighbor image resampling with precomputed coordinate maps.
- Implement 64-byte block copy, solid span filling, and transparency check (anyNonZero).
- Use fast fixed-point arithmetic for Porter-Duff alpha compositing.
- Add row-level transparency early-exit in Lanczos-2 decimation.
- Thread count is propagated from WebServer to TileGenerator, respecting ord::set_thread_count.

SaveImageTest.LabelsFollowTheVisibilityFlag performance (4096x3589 px) vs baseline:

| Configuration | Time (ms) | Speedup vs Baseline |
|---------------|-----------|---------------------|
| Baseline      | 3445      | 1.00x               |
| 1 thread      | 323       | 10.67x              |
| 4 threads     | 116       | 29.70x              |
| 16 threads    | 58        | 59.40x              |

Signed-off-by: Drew Lewis <cannada@google.com>
@calewis
calewis requested a review from a team as a code owner August 25, 2026 23:10
@calewis
calewis requested a review from maliberty August 25, 2026 23:10

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces multi-threaded tile rendering and various pixel-level optimizations to the TileGenerator and WebServer classes, including fast-path transparency checks, optimized alpha-compositing, and parallelized resampling. The review feedback provides valuable recommendations to improve the robustness and portability of these changes. Specifically, it suggests ensuring stable Z-ordering when layers are skipped, replacing compiler-specific inline attributes with standard C++ inline, handling arbitrary span sizes in fillSpan, marking buffers dirty early to handle early returns safely, and guarding against potential floating-point overflow during downsampling when the accumulated alpha is extremely small.

Comment thread src/web/src/tile_generator.cpp
Comment thread src/web/src/tile_generator.cpp Outdated
Comment thread src/web/src/tile_generator.cpp
Comment thread src/web/src/tile_generator.cpp Outdated
Comment on lines +2873 to +2880
static thread_local bool super_buffer_dirty = false;
if (super_buffer.size() != static_cast<size_t>(super_buffer_size)) {
super_buffer.assign(super_buffer_size, 0);
super_buffer_dirty = false;
} else if (super_buffer_dirty) {
std::memset(super_buffer.data(), 0, super_buffer_size);
super_buffer_dirty = false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Setting super_buffer_dirty = true at the very end of renderTileBuffer is risky because any early return or exception thrown during the rendering loop could bypass this assignment, leaving the thread-local super_buffer dirty without marking it as such. Setting super_buffer_dirty = true immediately after clearing/initializing the buffer at the start of the function guarantees that the buffer is always marked dirty for the next run, regardless of how the current run exits.

    static thread_local bool super_buffer_dirty = false;
    if (super_buffer.size() != static_cast<size_t>(super_buffer_size)) {
      super_buffer.assign(super_buffer_size, 0);
    } else if (super_buffer_dirty) {
      std::memset(super_buffer.data(), 0, super_buffer_size);
    }
    super_buffer_dirty = true;

Comment thread src/web/src/tile_generator.cpp
@calewis

calewis commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author
pr_comparison_aes_nangate45

This is the image for nangate45 aes to show another diff.

Address review comments from PR The-OpenROAD-Project#11239:
- Ensure deterministic Z-ordering in saveImageLayerOrder across visibility filters by advancing layer_z for all tech layers.
- Replace compiler-specific __attribute__((always_inline)) with standard C++ inline.
- Handle non-multiple-of-4 byte spans defensively in fillSpan.
- Set super_buffer_dirty at buffer initialization for exception/early-return safety.
- Guard against floating-point overflow in lanczos2Downsample when alpha is near zero (< 0.001f).

Signed-off-by: Drew Lewis <cannada@google.com>
Simplify thread-local super_buffer management in renderTileBuffer by
unconditionally zeroing the buffer with std::memset, eliminating the
redundant super_buffer_dirty state flag.

Signed-off-by: Drew Lewis <cannada@google.com>
@calewis

calewis commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author
pr_comparison_aes_asap7

AES on ASAP7 for fun after fixing the Gemini requests.

- Use ThreadPoolFuture::get() rather than wait() when joining the tile
  render and resample workers: wait() never rethrows, so an exception in a
  worker silently produced a partial image with a clean exit.
- Index the tile-span buffers with size_t.  kMaxDim clamps the final
  image, but the tile span can be ~2x larger, and the int products
  overflowed at the 16k cap.
- Zero Lanczos output pixels whose alpha rounds to 0 (ai < 0.5) instead of
  emitting (255,255,255,0), which defeated the transparent-buffer early
  exits downstream.
- Drop dead code in compositePixel (out_a can never be 0; the 16.16 lerp
  cannot leave [d, s]) and the unused <bit> include; make fillSpan assert
  whole pixels instead of writing a partial one.
- Add tests: saveImageLayerOrder honors visible_layers for tech layers,
  hidden tech layers are not drawn, and the rendered image is identical
  across thread counts.

Signed-off-by: Drew Lewis <cannada@google.com>
No behavior change.

- compositeTile(): one implementation of the sparse tile-onto-output
  composite, replacing the duplicated layer and label loops.  The
  per-pixel copy shortcut is dropped: compositePixel already starts with
  the same test.
- parallelRanges(): one implementation of the contiguous-range fan-out
  (with exception propagation) used by both the tile render and the
  resample passes.  The pool is no longer created for a single tile.
- WebServer::ensureGenerator() creates the TileGenerator and pushes the
  thread count once, replacing four creation sites and the per-call
  setThreadCount() pushes.
- Naming/comments: hw_threads -> num_threads (it is the configured count),
  the Lanczos lambda is unpremult (not blend), the anyNonZero comment no
  longer claims a SIMD path, and the static_assert on Color's layout sits
  with the memcpy helpers that rely on it.

Signed-off-by: Drew Lewis <cannada@google.com>
@github-actions github-actions Bot added size/L and removed size/M labels Aug 26, 2026
@maliberty

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b18a97b821

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/web/src/tile_generator.cpp
@maliberty

Copy link
Copy Markdown
Member

From Claude:

5.6x on 16 threads is worth calling out in review. Two likely causes: parallelRanges splits tiles into static contiguous ranges, so with sparse designs a thread that draws the dense core does all the work while the border-tile threads finish instantly — the early exits made the imbalance worse, not better. And what's left is largely memory-bandwidth-bound (~59 MB output buffer) plus the serial crop/encode tail. A dynamic or interleaved tile assignment would likely recover a good chunk of that.

@maliberty

Copy link
Copy Markdown
Member

Nice. Some of this should carry over the the tile rendering from the client (the single-threaded optimizations).

If a worker task in parallelRanges threw an exception, the previous loop
exited immediately on the first future get() failure. During stack unwinding,
the local stack lambda (e.g. render_tiles or resample_rows) was destroyed
before the thread pool. As the pool destructor drained the task queue,
remaining worker tasks could invoke the captured reference to the destroyed
lambda, causing undefined behavior.

Collect the first exception while continuing to call get() on every future so
all workers finish executing before rethrowing the exception.

Signed-off-by: Drew Lewis <cannada@google.com>
@calewis

calewis commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

From Claude:

5.6x on 16 threads is worth calling out in review. Two likely causes: parallelRanges splits tiles into static contiguous ranges, so with sparse designs a thread that draws the dense core does all the work while the border-tile threads finish instantly — the early exits made the imbalance worse, not better. And what's left is largely memory-bandwidth-bound (~59 MB output buffer) plus the serial crop/encode tail. A dynamic or interleaved tile assignment would likely recover a good chunk of that.

Was this a request to work on that or just a comment. TBH if we want to go faster for image generation I think threading the PNG writer (you can apparently parallelize the deflate part) is the next biggest win on typical images, but I think we've hit the point of diminishing returns, or at least my willingness to do more optimizations to this code.

My main reason for this PR was making an image at the end of our flow was taking 10-15s. For small modules (<10k cells) this was taking more time than the gpl or the resizer calls.

@maliberty

Copy link
Copy Markdown
Member

Just a comment in case you were looking for more improvement.

@maliberty
maliberty merged commit f4b5f2d into The-OpenROAD-Project:master Aug 26, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants