Skip to content

odb: let a table store its fields as columns - #11352

Open
oharboe wants to merge 2 commits into
The-OpenROAD-Project:masterfrom
oharboe:odb-soa-switch
Open

odb: let a table store its fields as columns#11352
oharboe wants to merge 2 commits into
The-OpenROAD-Project:masterfrom
oharboe:odb-soa-switch

Conversation

@oharboe

@oharboe oharboe commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

What it buys

Measured with one table converted — iterm_tbl — on five public ORFS designs across three PDKs:

compressed .odb 1.07x–1.44x smaller (1.38x aggregate)
peak RSS of read_db 8.8%–11.8% lower
uncompressed .odb 0.4%–1.5% smaller
read time, write time unchanged
design artifact peak RSS
nangate45/ariane133 1.41x 274.2 → 243.1 MB
nangate45/black_parrot 1.44x 360.9 → 318.2 MB
sky130hd/microwatt 1.40x 248.2 → 226.4 MB
asap7/aes (floorplan) 1.35x
asap7/aes (routed) 1.07x

Scripts, CSVs and figures: The-OpenROAD-Project/bazel-orfs#948

Why

A slot's serialized record is a row of fixed-width fields. Read as columns, most of them turn out to be constant or monotone — five of _dbITerm's ten 4-byte columns hold a single value on ariane133, and three more have a constant first difference 65–89% of the time. Array-of-structs interleaves those with the two columns that carry real entropy, so no compressor ever sees a run longer than a single field. Every record is distinct; only adjacency changes.

The same layout helps in memory for a different reason: a slot stops paying for padding and for fields most slots do not use. An access-point map is 24 bytes per slot and is empty on every iterm until pin access runs.

This is also why an external compressor cannot get there. Nothing in the byte stream says which bytes belong to the same field — only the schema knows that.

How

dbTable gains the option to store a table's fields as columns, in memory, on the wire, or both. Each is one line on the slot type:

static constexpr bool kSoaTable = true;         // columns in memory
static constexpr bool kFieldMajorTable = true;  // columns on the wire

iterm_tbl takes both and is the only table that does.

In memory. A slot keeps what dbObject needs: an address the public API can hand out, and the offset word that finds its page. Fields move to columns owned by the page, reached through accessors that return references — so the migration is a rename, iterm->net_ becomes iterm->net(), and an assignment reads as it did. Nothing outside odb changes: a dbITerm* is still the slot's address, so every tool compiles untouched.

On the wire. An opted-in table writes its pages in blocks whose bytes are permuted field-major, produced by the type's existing operator<<. There is no per-type serializer — that was the condition this was worth doing under at all. A block spans several pages: raising a table's page_size would give the same adjacency and must not be used, because an id is page_addr | slot. Records of different lengths inside a block are split by length, and past 64 distinct lengths the block is written verbatim, so a table the layout cannot help is simply not helped.

Compatibility. kSchemaFieldMajorBlocks gates the read. Files older than it take the per-slot path unchanged — deliberately simple rather than fast, since it only runs on files written before the bump.

Tests are intent-based, no goldens (#11281): a round trip preserves every iterm's id, instance, terminal and net; writing what was read reproduces the same bytes; and allocation order survives a reload — rebuilding a free list from the allocation bitmap would still be valid and would still renumber every later object, which reaches placement and routing through dbSet iteration order.

Deliberately not included

Each is a further gain this makes available, measured, left out so the change stays one concern:

box_tbl + sbox_tbl on the wire 1.38x → 1.53x
every table on the wire 2.51x, but 7.1x read time and 2.3x peak RSS — blocks are sized by sizeof(T), which does not bind for records carrying a heap payload. Slot-granular blocks would fix it.
columns in memory for other tables iterm_tbl is the largest; box_tbl and sbox_tbl are next
dropping the derivable oid_ 4 bytes a slot across every table; also the field behind #9960
dropping the derivable prev_net_iterm_ 7.2 MB of a 10.8 MB column total on one design

dbTable stores a table's fields inside its slots. This adds the option to
store them as columns instead -- one array per field -- in memory, on the
wire, or both. Each is a one-line opt-in on the slot type:

    static constexpr bool kSoaTable = true;         // columns in memory
    static constexpr bool kFieldMajorTable = true;  // columns on the wire

iterm_tbl takes both and is the only table that does. Measured with just
that one table converted, on five public ORFS designs across three PDKs:
compressed .odb 1.07x-1.44x smaller (1.38x aggregate), peak RSS of read_db
8.8%-11.8% lower, read and write time unchanged, uncompressed size 0.4%-1.5%
smaller.

Why it works: a slot's record is a row of fixed-width fields, and read as
columns most of them are constant or monotone -- five of _dbITerm's ten
4-byte columns hold a single value on nangate45/ariane133. Array-of-structs
interleaves those with the two columns carrying real entropy, so no
compressor sees a run longer than one field. Every record is distinct; only
adjacency changes.

In memory. A slot keeps what dbObject needs: an address the public API can
hand out, and the offset word that finds its page. Fields move to columns
owned by the page, reached through accessors returning references, so the
migration is a rename -- iterm->net_ becomes iterm->net(), and an
assignment reads as it did. Nothing outside odb changes: a dbITerm* is
still the slot's address. Access points are keyed by slot rather than given
a column, since no iterm has one until pin access runs and a dense column
would spend 24 bytes a slot on an empty map.

On the wire. An opted-in table writes its pages in blocks whose bytes are
permuted field-major, produced by the type's existing operator<< -- there
is no per-type serializer, which was the condition this was worth doing
under at all. A block spans several pages: raising a table's page_size
would give the same adjacency and must not be used, because an id is
page_addr | slot. Records of different lengths inside a block are split by
length, and past 64 distinct lengths the block is written verbatim, so a
table the layout cannot help is simply not helped.

Compatibility. kSchemaFieldMajorBlocks gates the read; files older than it
take the per-slot path unchanged, which is deliberately simple rather than
fast since it only runs on files written before the bump.

Tests are intent-based, no goldens (see The-OpenROAD-Project#11281): a round trip preserves
every iterm's id, instance, terminal and net; writing what was read
reproduces the same bytes; and allocation order survives a reload --
rebuilding a free list from the allocation bitmap would still be valid and
would still renumber every later object, which reaches placement and
routing through dbSet iteration order.

Further gains this makes available, measured, deliberately not included so
this change stays one concern: opting in box_tbl and sbox_tbl takes the
artifact to 1.53x; every table takes it to 2.51x but costs 7.1x read time
until blocks are bounded by bytes rather than pages; oid_ is stored per
slot though derivable from the slot index, worth 4 bytes a slot across
every table and also the field behind The-OpenROAD-Project#9960; prev_net_iterm_ is derivable
from the next_ chain.

Evidence, scripts and per-design numbers:
The-OpenROAD-Project/bazel-orfs#948

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
@oharboe
oharboe requested a review from a team as a code owner September 7, 2026 19:26

@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 a field-major block serialization format and a Structure-of-Arrays (SoA) layout for dbTable, specifically refactoring _dbITerm to store its fields in columns to improve compression and performance. Unit tests are also added to verify that round-trips preserve database IDs, connectivity, and allocation order. The review feedback highlights three critical security and stability issues in dbTable.inc regarding missing input validation when reading from a stream: potential infinite loops or out-of-bounds array access due to unvalidated page counts, potential Out-Of-Memory (OOM) crashes from unvalidated class counts, and potential out-of-bounds reads due to a lack of bounds checking on class indices.

Comment thread src/odb/src/db/dbTable.inc
Comment thread src/odb/src/db/dbTable.inc
Comment thread src/odb/src/db/dbTable.inc
Three values a block header carries are read straight from the file and
then used to bound a loop, size an allocation, and index a vector. The
first is a defect this change introduced: a page count of zero makes
readBlocks spin forever, which is worse than a crash because it hangs
with no diagnostic. The other two are cheap to guard while the code is
being touched.

Everything writeBlock can emit stays in range, so these fire only on a
file that is corrupt or was not written by this code.

Also formats the changed lines. dbTable.inc is left as it is: CI does not
check .inc, and the file does not satisfy clang-format on master either,
so reformatting it would be churn on someone else's lines.

Signed-off-by: Øyvind Harboe <oyvind.harboe@zylin.com>
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.

1 participant