Architecture

GDS file ──▶ load_gds ──▶ flatten (apply transforms) ──▶ FlatLayout
                                                              │
                        compute *eager* virtual layers ◀──────┤
                                                              ▼
PDK + deck (YAML) ──▶ rules ──▶ run each check ──▶ Violations ──▶ .lyrdb
                                    │
                                    ▼
                      MergedCache (tiled merge, per-layer halo,
                      lazy virtual layers, region stitching,
                      net extraction) — shared across all rules

Design goals

gdscheck targets real, full-chip layouts, not just small test patterns. That drives two constraints that shape everything below:

  • Memory must stay bounded regardless of chip size. A dense layer like Activ or GatPoly on a full SoC top cell can carry many millions of shapes; no single check may ever union or hold that in memory all at once.

  • A single run pays once for shared geometry. A deck touches the same layer from several rules (e.g. Metal2 from a width, a space and a density rule); the merged geometry for a layer is computed once and reused, not recomputed per rule.

Hierarchy flattening

flatten.rs resolves every StructRef/ArrayRef in the cell tree under the requested top cell by composing affine transforms (translation, rotation, reflection, magnification, and array repetition) down the hierarchy, producing a single FlatLayout — boundaries and text labels indexed by (gds_layer, gds_datatype) in top-cell coordinates. Only the layers actually referenced by the deck (plus any layers feeding a referenced virtual layer, transitively, plus the connectivity graph’s layers if net-aware checks are running) are flattened — an unreferenced layer never enters memory.

The tiled merge cache

MergedCache (merge.rs) is the shared, per-layer merged-geometry cache every geometric check reads from. Instead of unioning a whole layer’s shapes in one operation, it splits the chip into fixed-size tiles (TILE_UM, 20 µm) and merges each tile’s shapes independently — a tile’s output only depends on shapes within that tile plus a halo margin around it, so tiles merge in parallel and none of them ever holds more than a local neighbourhood’s worth of geometry.

A check calls MergedCache::ensure for a layer; the first check to touch a layer pays for its tiled merge, every later check reading the same layer reuses the cached result.

Per-layer halos

A tile’s halo must be at least as large as the biggest distance any rule measures on that layer, or a shape just across the tile boundary could be missed and a real violation (or a real pass) computed wrong at the tile’s edge. The halo is per layer, not per run: before the merge cache is built, lib.rs scans every distance-based rule (min_width, max_width, exact_width, the gate-length rules, min_space, min_notch, min_enclosure, max_enclosure) and records, per layer, the largest value referencing it. A deck-wide halo would let one coarse rule (e.g. a 1500 µm max_width on a guard layer) inflate the merge of every fine layer in the deck — for a dense layer that difference is the difference between a normal run and one that never finishes.

A min_space/min_notch rule referencing a layer with no shapes at all in the current design is skipped when computing halo (an empty partner must not inflate the other layer’s halo just because a rule exists on paper).

Region stitching

Some questions can’t be answered from one tile in isolation: “is this whole connected region wide enough,” “does this whole region touch a text label anywhere,” “does this whole region interact with another layer.” stitch_regions/stitch_labeled (merge.rs) answer these without ever reconstructing or globally unioning the source geometry: each tile independently computes the piece of every region that falls in its core (as opposed to its halo), and pieces in adjacent tiles are joined with a union-find whenever their shared core edge shows continuous coverage. A region’s aggregate property — total area, a representative marker point, or a caller’s boolean predicate (“does any piece touch layer B”) — is then just an OR/sum reduction over its pieces, which stays cheap however many tiles a real-world region spans. The inside selector is the one that reduces with AND instead: every piece must be covered, not just one.

This is what lets checks like windowed-density’s plate analysis, or the region selectors behind the overlapping/interacting/inside/covering virtual layers (and with_text), run correctly on chip-spanning regions while only ever touching tile-local, halo-bounded geometry.

Lazy virtual layers

A PDK’s virtual_layers: (declared in pdk.yml as sentences, see Virtual layers) are built one of two ways, and which one is decided by the checks that read a layer, not declared:

  • Lazy (every layer, unless a rule needs otherwise) — registered with the MergedCache as a TiledVirtual: a synthetic (layer, datatype) key built per tile, on first ensure, by applying a boolean/selection/morphological op to its source layers’ tiles (each recursively ensured in turn). A lazy virtual layer costs nothing until something actually asks for it, and its memory profile is the same tile+halo bound as any drawn layer — what a dense, multi-step derivation (like the AntHError chain behind IHP’s antenna rule Ant.h) needs to stay bounded on a full chip.

  • Eager — computed once, up front, as ordinary boundaries inserted into the flattened layout (pdk.rs → compute_virtual_layers): the layers a whole-layout check (checks::reads_layout) reads, and the ones made by inside_ring. pdk.rs → eager_layers names them from the rules about to run, and refuses a rule whose layer cannot be built that way before the layout is read.

A lazy virtual layer that feeds another must have its own halo raised to cover the downstream layer’s needs, transitively — lib.rs propagates this before the merge cache is constructed.

Net extraction

Net-aware checks (the antenna-ratio family, gate-connected minimum area, different-net spacing) need to know which shapes are electrically the same net. connectivity.rs extracts nets from geometry alone: the PDK declares a list of connect specs, each a connector layer (a via or contact) and the conductor layers it bridges. A layer’s own connected regions are already available via stitch_labeled; a connector sits inside every layer it joins, so a single point of the connector lands in one region of each — those regions are unioned into one net via a union-find over (layer, region) nodes. Extraction is lazy: it only runs if the deck actually has a net-aware check (see NET_AWARE_CHECKS), so a geometry-only deck never pays for it, and it can be disabled outright with --no-connectivity.

Memory bounding and eviction

Two further mechanisms keep peak memory flat as a deck grows:

  • Eviction. lib.rs records the last rule index that references each layer before running any rule. Right after that rule runs, the layer’s cached tiles (and any stitched regions) are dropped from the MergedCache. A deck touching dozens of layers only keeps a handful resident at any point, not all of them at once.

  • Per-check-run scoping. Only layers actually reachable from the current deck’s rules (transitively through virtual-layer sources and the connectivity graph) are flattened from the GDS in the first place — a layer the deck never references never exists in memory at all.

Parallelism

Every tile-level operation (raw-layer merge, virtual-layer composition, region stitching, per-window density) fans out across rayon’s thread pool, one task per tile key; the --threads flag (default: all logical cores) controls the pool size. Because tiles are independent (correctness depends only on the halo, not on execution order), this parallelism is exact, not an approximation — the result of a threaded run is identical to a single-threaded one.

The rules themselves run one after another over the merge cache, which the checks read through a lock (SharedCache): a call takes the lock for its duration and hands back what the cache holds by Arc, so a check computes on its copies outside the lock, and a build — a merge, a stitch, a derived layer — runs under the lock on a thread pool of its own, so the thread that holds the lock never steals a job that would take it. Up to GDSCHECK_WAVE rules (four by default) run side by side on threads of their own, admitted against the memory plan (what each rule’s layers would add, plus a reserve for its working set); a rule that registers derived layers of its own (the forbidden family) runs alone. The waves overlap the rules that keep few cores busy outside the lock — the density rules of a 4 mm² design took the run from 65 to 58 s — and nothing of a rule’s time under the lock, which is a build: on the gf180 reference design the gain came from composing the edge layers’ tiles in parallel instead. GDSCHECK_WAVE=1 runs the rules one at a time; the result is the same either way.