Skip to content

Conversation

@huitseeker
Copy link
Contributor

@huitseeker huitseeker commented Dec 14, 2025

We were doing wasteful round-trip conversions in MastForest serialization. The old flow extracted decorator data from centralized storage, formatted it per-node for serialization, then reconstructed centralized storage during deserialization.

Solution

Serialize DebugInfo directly in CSR format. Deserialize it first, then use builders to reconstruct nodes that link to the already-populated decorator storage.

Two distinct insertion paths:

  • add_to_forest(): registers decorators (normal assembly, part of MastForestContributor)
  • add_to_forest_relaxed(): assumes decorators already in debug_info (deserialization)

Prior Work: Padded Serialization

Earlier PRs in this stack switched to padded operation serialization with batch metadata. Operations and decorator indices are written in their internal padded form for exact reconstruction.

This eliminated the "padding wrinkle" - complex conversions between padded and unpadded representations. Decorator indices in memory now match decorator indices on disk, making direct DebugInfo serialization straightforward.

Fixes #2448.

@huitseeker huitseeker changed the title Direct DebugInfo Serialization (3/3) feat: direct DebugInfo serialization (3/3) Dec 14, 2025
@huitseeker huitseeker force-pushed the serialize-debuginfo-directly branch from 8ec4d03 to 7df4114 Compare December 14, 2025 19:01
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! I left some questions/comments inline.

Comment on lines 163 to 169
// Nodes with no decorators are valid
if decorator_ids.is_empty() && op_indptr_for_dec_ids.is_empty() {
// All node pointers must be 0
if node_indptr_for_op_idx.iter().all(|&ptr| ptr == 0) {
return Ok(Self {
decorator_ids,
op_indptr_for_dec_ids,
node_indptr_for_op_idx,
});
} else {
return Err(DecoratorIndexError::InternalStructure);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Under what circumstances could this happen? That is, why would there be decorator storage initialized with nodes, but with no decorators?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This occurs when nodes exist in the MastForest but have no decorators attached to any operations. We've decided to use a dense representation:

  • Serialize all indptr entries sequentially [0, 0, 0, 5, 5, 5, 5, 8, ...], including consecutive equal values that represent empty nodes (dense).
  • Versus only serialize non-zero ranges as (node_id, start, end) tuples, skipping empty nodes entirely (sparse).

The sparse encoding overhead (storing node IDs + offsets) exceeds savings from skipping empty node entries — and the dense representation is simpler and more predictable. See the script at https://gist.github.com/huitseeker/7379e2eecffd7020ae577e986057a400 (linked in the PR)

Copy link
Contributor

@plafer plafer left a comment

Choose a reason for hiding this comment

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

LGTM! MastForest serialization is much cleaner now 🙂

Comment on lines +123 to +146
// Validate before_enter CSR
let before_slice = self.node_indptr_for_before.as_slice();
if !before_slice.is_empty() {
if before_slice[0] != 0 {
return Err("node_indptr_for_before must start at 0".to_string());
}

for window in before_slice.windows(2) {
if window[0] > window[1] {
return Err(format!(
"node_indptr_for_before not monotonic: {} > {}",
window[0], window[1]
));
}
}

if *before_slice.last().unwrap() != self.before_enter_decorators.len() {
return Err(format!(
"node_indptr_for_before end {} doesn't match before_enter_decorators length {}",
before_slice.last().unwrap(),
self.before_enter_decorators.len()
));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Future PR: this CSR validation logic is copy/pasted for all instances for CSRs that we have. Ideally we would have a CsrMatrix type where we would write that once, and build all or concrete CSR types from it. It would look something like

pub struct CsrMatrix<Data, Idx> {
    data: Vec<Data>,
    indptr: IndexVec<Idx, usize>
}

pub struct NodeToDecoratorIds {
  before_enter_decorators: CsrMatrix<DecoratorId, MastNodeId>,
  after_exit_decorators: CsrMatrix<DecoratorId, MastNodeId>
}

Ideally too we're able to reuse that type to define the "two-level CSR" that OpToDecoratorIds needs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@huitseeker huitseeker force-pushed the delta-encode-indptr branch from 7724be5 to aad51b4 Compare January 7, 2026 07:11
@huitseeker huitseeker force-pushed the serialize-debuginfo-directly branch 2 times, most recently from 6066667 to 26ac7cc Compare January 7, 2026 07:16
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! I left some comments inline. After these are addressed, we should be good to merge.

Comment on lines 368 to 376
// Deserialize op_indptr_for_dec_ids
let op_indptr_u32: Vec<u32> = Deserializable::read_from(source)?;
let op_indptr_for_dec_ids: Vec<usize> =
op_indptr_u32.iter().map(|&idx| idx as usize).collect();

// Deserialize node_indptr_for_op_idx
let node_indptr_u32: Vec<u32> = Deserializable::read_from(source)?;
let node_indptr_for_op_idx: Vec<usize> =
node_indptr_u32.iter().map(|&idx| idx as usize).collect();
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason why we want to write the pointers as u32 rather than usize? Is it because usize uses variable-length encoding?

Copy link
Contributor Author

@huitseeker huitseeker Jan 7, 2026

Choose a reason for hiding this comment

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

They're invalid if >= u32::max. I'll hide that.

Copy link
Contributor

Choose a reason for hiding this comment

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

The usize serialization/deserialization we have will work fine cross-platform as long as the serialized values don't exceed the valid range for usize on the target platform (ref code here).

Copy link
Contributor Author

@huitseeker huitseeker Jan 7, 2026

Choose a reason for hiding this comment

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

Yes. I edited the comment which was misleading, this is not primarily about platforms (though that's a valid, if minor concern).

I thought that we had a limit (of u32::MAX) on the indices of decorators attached to a given op, and the indices of ops attached to a given node. Indeed the # of ops per node, and the total # of decorators are bound by u32::MAX. I therefore thought there are two equivalent ways of addressing the correctness issue:

  • serializing / deserializing as usize, then checking every read value is bound by u32::MAX,
  • serializing / deserializing as u32.

The later seemed simpler, allocation aside. I now realize that I am not sure any of those are actually bound by u32::MAX. We at least clearly can have values in op_indptr_for_dec_ids that can exceed u32::MAX, and because they are offsets in that array, same goes for the values of node_indptr_for_op_idx. See other comment.

Thanks for the pushback. Will edit.

Comment on lines 333 to 334
// Serialize indptr arrays as u32 for cross-platform compatibility
// (usize varies between 32-bit and 64-bit platforms)
Copy link
Contributor

Choose a reason for hiding this comment

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

Our usize serialization is "cross-platform safe" (as long as the actual values stay within 32-bit range, and if not, we have an issue here anyway).

Copy link
Contributor Author

@huitseeker huitseeker Jan 7, 2026

Choose a reason for hiding this comment

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

if not, we have an issue here anyway

I'm just not sure that's true. Assume for simplicity that you have precisely decorators.len() == u32::MAX / 100 (and that all quantities divide evenly). For each block, do the following:

  • name op_idx the index of each operation in the block (we enforce op_idx <= u32::MAX),
  • if op_idx is odd, attach all odd-indexed decorators in the decorators array to it,
  • symmetrically if op_idx is even, attach all even-indexed decorators in the decorators array to it.

There's no trivial succinct encoding of that map, so decorator_ids.len() is decorators.len() / 2 * num_ops where num_ops is the total number of operations in the MastForest.

Each operation has to index into a u32::MAX / 200-long slice into the decorator_ids array (which values are all u32s). The 201st such slice has indexes ending at an offset > u32::MAX (the start and end indices of that slice are kept in op_indptr_for_dec_ids by definition). Now assume the MastForest has in aggregate over 200 operations — no one has flagrantly broken anything documented about the protocol, yet we have values in op_indptr_for_dec_ids above the u32 range.

It takes a bit more work to see the same issue at work in node_indptr_for_op_idx but it's the same idea.

@huitseeker huitseeker force-pushed the serialize-debuginfo-directly branch 2 times, most recently from f385c11 to b2ba6a4 Compare January 7, 2026 09:53
@huitseeker huitseeker force-pushed the delta-encode-indptr branch 2 times, most recently from ce2fad2 to 842094c Compare January 7, 2026 18:42
@huitseeker huitseeker force-pushed the serialize-debuginfo-directly branch from 7c4a99e to c88a02a Compare January 7, 2026 18:42
@huitseeker huitseeker force-pushed the delta-encode-indptr branch from 842094c to 1aa41a1 Compare January 7, 2026 19:01
@huitseeker huitseeker force-pushed the serialize-debuginfo-directly branch from c88a02a to 65a4972 Compare January 7, 2026 19:04
Base automatically changed from delta-encode-indptr to next January 7, 2026 19:20
…andling fixes

Adds comprehensive round-trip tests for MastForest serialization/deserialization
with direct DebugInfo handling, including edge cases for CSR (Compressed Sparse Row)
format.

Tests cover:
- Basic MastForest with decorators roundtrip
- Empty CSR arrays for nodes without decorators
- Sentinel values in op_indptr for empty nodes after decorated nodes
- Multiple operations and decorators per node
- All node types with various decorator configurations

CSR handling fixes:
- Allow empty CSR arrays when nodes have no decorators
- Add sentinel values to op_indptr for proper indexing
- Changed `OpToDecoratorIds::from_components` and
  `NodeToDecoratorIds::from_components` visibility from `pub` to
  `pub(super)` as they're only used internally for deserialization
- Added serialization helper methods `write_into()` and `read_from()`
  to `OpToDecoratorIds` and `NodeToDecoratorIds` to break down large
  DebugInfo serialization implementation
- Refactored DebugInfo (de)serialization to call helper methods
  instead of directly accessing CSR fields
- Moved decorator storage tests to separate file
  (`decorator_storage/tests.rs`) following codebase pattern, reducing
  main file from 1669 to 871 lines
- Added comment clarifying wire format compatibility for unused
  `_decorator_count` variable in MastForest deserialization
- Update validation doc comments to reflect valid edge cases
- Update serialization format description in mod.rs
- Simplify decorator_infos serialization using Vec::write_into
- Add Deserializable impl for DecoratorId to simplify deserialization
- Simplify write_into/read_from in OpToDecoratorIds and NodeToDecoratorIds
  to use DecoratorId's serialization traits directly
- Serialize indptr arrays as u32 (indices into u32-bounded arrays)
- Update serialization format description in module docs
- Add comment about procedure name validation at MastForest level
- Update trusted source comment for deserialization
- Remove unnecessary pub(super) from struct fields
Add TryFrom<Vec<T>> impl that validates length <= u32::MAX, enforcing
the u32-indexed invariant. Deserialization uses this for validation.
The indptr arrays (op_indptr_for_dec_ids, node_indptr_for_op_idx,
node_indptr_for_before, node_indptr_for_after) are indices that can
exceed u32::MAX since decorator_ids can grow arbitrarily large due
to decorator reuse across many operations.

Use winter-utils Serializable/Deserializable impls for usize and
the IndexVec impls to serialize these arrays directly.
@huitseeker huitseeker force-pushed the serialize-debuginfo-directly branch from 65a4972 to 7dc6b6e Compare January 7, 2026 19:22
@huitseeker huitseeker merged commit d2f608b into next Jan 7, 2026
16 checks passed
@huitseeker huitseeker deleted the serialize-debuginfo-directly branch January 7, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify MastForest serialization by directly serializing DebugInfo

4 participants