Task: Store each trade kind in its own column
Table of Contents
This page documents a task in the Decide the XML strategy for ORE documents story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
Remove the 340 KB zero fill from each trade without a variant, by moving each instrument kind to a column the portfolio owns.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Decide the XML strategy for ORE documents |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-10-20 |
3. Acceptance
- The design is written in the task, with the choice of field kind and the effect on the writer and projections.
- The generated trade record is under 1 KB.
- The portfolio read test drops by the zero fill share, measured with callgrind, and the corpus tests pass.
- The drift check and the benchmark gate pass.
4. Plan
(Implementation strategy. Written when work starts; key decisions
are distilled into the parent story's * Decisions at close, but the
plan itself stays — it is the historical record of what we did.)
4.1. Problem
The schema's trade element holds one instrument kind out of about 190 element
names (struct trade has 194 lines between its braces, measured at the
current main). The generator turns that choice into one inline member per name, so
struct trade in xml/generated/portfolio.hpp is 346,624 bytes. Every new
trade zeroes all of it. Some names share a type: four members are a
swap_data. The zero fill is 33 percent of the portfolio read test.
The problem is in the binding record only. The domain types are separate and the projections copy out of this record.
4.2. Constraint
No variant. Our approach is data-oriented: plain structs, columns, indices. A variant hides a tag and a union behind a type and gives each trade the size of its largest kind.
4.3. Design
Store each kind as a column.
tradekeepspresent,id,trade_type,envelope,trade_actions, a kind tag and an index into the column for that kind.portfolioowns one container per kind type, not per element name. The fourswap_datanames share one column, and the tag tells them apart.- The reader sees the element name, finds its tag in the table, appends to the column and stores the index.
- The writer reads the tag, follows the index and writes the element under the name the tag gives.
This is the same idiom as indexed_nested: a member is an index into a
container the parent owns. The new part is that the column belongs to the
portfolio and not to the trade, and that one trade uses at most one of them.
4.4. Findings
Read from generate_xml_bindings.py, binding.cpp, record.cpp, the
emitter, record_diff.cpp and the projections, at main after #3580.
- Three types have the problem, not one:
trade,component_tradeandcomponent_sub_trade, each with about 190 members.derivativeis small. tradehas one parent,portfolio, which holds it in a deque.component_tradesits in an arena beside an index vector in three records (composite_trade_componentsand two more).component_sub_tradesits in an arena incomposite_trade_components, and also by value intrs_underlying_data. A by-value member has the 340 KB cost in the parent too.- The kinds are the children of an
xs:choice, soCHOICE_CHILDRENholds them. Each of the three types has 190 elements, 187 of them choice children. All 187 are nested, none repeats, and they name 149 distinct types. The other three areTradeType(SubTradeType),EnvelopeandTradeActions. The three types name the same 149 kinds. (An earlier version of this page said the kinds came fromexpand(). That was wrong: a read of the schema and a count in the generator showedexpand()produces the 19-member leg choice and smaller groups, not the trade kinds.) - The widest other choice in any schema has 26 members.
- Every projection reads a trade through
record_reader::child(name)andrecord_writer::child(name). They name the element and the table resolves the offset. No projection names a C++ member oftrade. - Only the engine,
record.cpp, the emitter andrecord_diff.cppknow the layout.
4.5. Answers to the open questions
Where the column lives. In the record that holds the trade-like row, as a sibling of its deque or arena. For
tradethat isportfolio. Forcomponent_tradeandcomponent_sub_tradeit is each record that holds them, includingtrs_underlying_data. One column per kind type, not per element name. The columns sit in one struct the parent owns (see answer 3). A trade is always reached from its parent, so the engine, the reader, the writer, the emitter and the diff take the columns pointer from the parent's field row when they step into a trade.Rejected: an out-of-line payload owned by the trade. It needs one allocation per trade and a destructor entry in the table, and it gives no column to scan.
- New field kind. A new kind,
column_nested, besideindexed_nested. Anindexed_nestedmember keeps its index vector and its arena in the same record. Acolumn_nestedmember lives in the trade-like row and names a vector in the columns struct:arena_offsetis the vector's offset in that struct andrepeatedare its ops. The trade stores onestd::uint32_tindex, not a vector of indices, because at most one kind appears per trade. The parent's field row gainscolumns_offset, the offset of the columns struct in the parent. Detection. A type is columnar when it has at least 64 nested choice children (
COLUMN_CHOICE_MEMBERS). The three trade types have 187. The next widest choice has 26. The generator readsCHOICE_CHILDREN, so the choice children of a columnar type are its column members. The presence bit of the chosen member is the tag, so the row needs no tag field, only the index.The columns live in one struct per distinct set of kind types (
trade_kind_columns: one vector of rows per kind type). The three trade types share one struct, because they name the same kinds. Each parent that holds a columnar type owns one such struct as a member. The field row of the parent's trade member carries its offset in a newcolumns_offset, and each kind member of the trade carries the offset of its vector inside the struct. The offset inside the struct is the same in every parent, which is why the trade's own table can hold it. The engine and the readers need only a pointer to the struct, not to the parent.- Projections. No change. They already name the element, and
child(name)resolves a column member through the presence bit, the index and the columns struct.has(name)tests the bit. The reader and writer classes carry a pointer to the columns struct, which is the only new state. - The deque. Not needed for trades afterwards.
member_countsis structural, so a trade with a handful of members is no longer wide and becomes a vector with no change to the rule. The kind rows are narrow, so their columns are vectors. A column grows only when a new trade starts, and no frame is open on a row of that column then. If no record is wide afterwards, the wide-record rule from #3579 has no user and goes in a separate step. - The cut edges. The edges from a trade to its kinds are no longer held by
value, so the generator leaves them out of the ordering graph. Cycles that
ran through a trade, and the arenas and index vectors that broke them,
should disappear. A kind record such as
composite_trade_dataholds a components record that holds trades; the trades hold no kind. This is an expectation to check against the regenerated output.
4.6. Risks
- The columns pointer touches the reader, writer, emitter and diff, so it is the largest part of the work. Each already passes a record pointer, so the change is one more argument, but it is five files.
- A row's index must stay valid when a document is copied or moved. An index survives both. A raw pointer to the column would not.
- Each parent that holds a trade pays for 149 empty vectors: about 3.6 KB of zero fill, against 340 KB per trade before. A composite trade has one such parent, so it pays once.
sub_tradeintrs_underlying_datameans that record owns a columns struct and a container of trades. It is the test case for a parent that is not the portfolio.
4.7. Steps
- Answer the open questions by reading the generator, the engine and the projections. Write the answers here. (Done.)
- Add the columns struct, the index member and the
column_nestedrows to the generator, behind the choice detection. - Teach the engine, the reader, the writer, the emitter and the diff to use the columns pointer.
- Run the projections and the corpus tests. They should need no change.
- Measure with callgrind and record the benchmark.
- Remove the wide-record rule if nothing uses it.
5. Measurements
Every number this task claims about a tree, with the command that produced it
and the commit it was measured at. A number without both is not a measurement,
because a clean-up record describes a tree and trunk moves under a branch: a
consumer count, a gate's component count and a suite's size all change with no
code changing, and the reader of a bare number cannot tell which tree it came
from. Where the raw output matters, put it in an evidence/ file beside the
task and name that file in the Command column.
| What | Value | Command | Commit |
|---|---|---|---|
| sizeof of a trade, before | 346,624 bytes | a throwaway program printing sizeof of every generated struct |
7515c50292 |
| sizeof of a trade, after | 432 bytes; the columns struct is 3,576 bytes; a derivative is 4,480 bytes | a throwaway file that names each size in a template error, compiled with clang++ -fsyntax-only |
ec1c3ca051 |
| Instructions, portfolio import test, before | 3,070,700,246; import_portfolio_with_context 1,952,347,014; memset 33% |
valgrind --tool=callgrind ores.ore.core.tests "import_portfolio_all_ore_example_files_can_be_parsed" |
a6c7e6f7da |
| Instructions, same test, after | 1,864,475,726; import_portfolio_with_context 746,129,229; memset 66,709,706 (3.6%) |
same command | ec1c3ca051 |
| Instructions, same test, before #3579 | 4,028,918,281 | same command | 7515c50292 |
| Cut edges and arenas in the portfolio tables | none after; eight before | Schema.cut_members printed after order_types |
ec1c3ca051 |
| Largest record by member count | 491 (total_return_swap_data); a trade has 14 |
the generator's member count, printed once | ec1c3ca051 |
| Generator tests | 39 pass, four of them new | python3 -m pytest projects/ores.codegen/tests/test_generate_xml_bindings_derivation.py |
ec1c3ca051 |
| Full test binary | 377 cases, 475,405 assertions, all pass | ores.ore.core.tests |
ec1c3ca051 |
| Drift check, census, benchmark gate | pass | check_xml_bindings_drift.py, check_xml_family_coverage.py, check_xml_benchmark_recorded.py |
ec1c3ca051 |
6. Notes
7. Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario)
that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
8. PRs
| PR | Title |
|---|---|
| #3583 | [ore] Store each trade kind in its own column |
9. Review
| Comment summary | File | Decision | Notes |
|---|---|---|---|
10. Result
Each instrument kind of a trade now sits in a column. The trade, the component
trade and the component sub trade keep their common fields and one
std::uint32_t index. The presence bit of the chosen kind says which kind it
is. The holder of the trade owns a trade_kind_columns struct: one vector of
rows per kind type, 149 of them. The three trade types name the same kinds and
share the struct. A field row of the holder says where the struct is
(columns_offset), and a kind row says where its vector is inside the struct.
The engine, the reader, the writer, the emitter and the diff take that pointer
from the holder's row when they step into a trade.
A trade falls from 346,624 to 432 bytes. Reading the portfolio test falls from 3,070,700,246 to 1,864,475,726 instructions, 39.3%. Since before the deque change (4,028,918,281) it falls 53.7%. The zero fill is 3.6% of the test and was 33%.
The kinds no longer sit inside a trade by value, so the type cycles through a
trade end. The eight cut edges and their arenas and index vectors are gone, and
derivative holds its component_trade by value. No record is wide, so the
wide-record rule of #3579 had no user and is removed with the deque. The
generator keeps the arena for a schema that recurses, and its tests still cover
it. A test now pins the size of the three trade types under 1 KB, in place of
the one that pinned the address of a trade.
Two things the design note said were wrong. The kinds are xs:choice
children, not substitution members. The projections did need a change: four
reads of the composite and total return swap projections called indexed(),
and read the nested trades with children() and child() now.
A document that states two kinds under one trade is now refused by the reader and by the writer. The inline layout accepted it and kept both. No document in the corpus does it.
Not done: MSVC is not built. The 149 empty vectors of a columns struct cost 3,576 bytes for each holder of a trade, which is one per composite trade.