odb: let a table store its fields as columns - #11352
Conversation
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>
There was a problem hiding this comment.
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.
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>
16f11b1 to
b97f49f
Compare
What it buys
Measured with one table converted —
iterm_tbl— on five public ORFS designs across three PDKs:.odbread_db.odbnangate45/ariane133nangate45/black_parrotsky130hd/microwattasap7/aes(floorplan)asap7/aes(routed)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 onariane133, 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
dbTablegains 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:iterm_tbltakes both and is the only table that does.In memory. A slot keeps what
dbObjectneeds: 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_becomesiterm->net(), and an assignment reads as it did. Nothing outside odb changes: adbITerm*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'spage_sizewould give the same adjacency and must not be used, because an id ispage_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.
kSchemaFieldMajorBlocksgates 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
dbSetiteration order.Deliberately not included
Each is a further gain this makes available, measured, left out so the change stays one concern:
box_tbl+sbox_tblon the wiresizeof(T), which does not bind for records carrying a heap payload. Slot-granular blocks would fix it.iterm_tblis the largest;box_tblandsbox_tblare nextoid_prev_net_iterm_