[Data] Dictionary-encode schema_data in MCAP blocks, and document the format - #65913
Open
marwan116 wants to merge 2 commits into
Open
[Data] Dictionary-encode schema_data in MCAP blocks, and document the format#65913marwan116 wants to merge 2 commits into
marwan116 wants to merge 2 commits into
Conversation
… format
A schema is a per-channel attribute in MCAP: the file stores each definition
once, and every channel using it refers to it by id. `_message_to_dict`
copies the whole definition onto every row, so a block holds one value as
many times as it has messages. Real ROS 2 definitions are not small --
`sensor_msgs/msg/Imu` is 2,581 bytes, `sensor_msgs/msg/CompressedImage`
1,505 -- so on a high-rate topic with a small payload the column dominates
the block.
Dictionary-encode the column once per block, on the way out of
`_read_stream`. Arrow keeps one copy of each distinct definition and gives
every row an int32 index into it; the value a row reads back is unchanged.
Measured on a FAST-LIVO ROS 2 recording (105 s, 24,456 messages: Livox IMU
at ~200 Hz, Livox lidar, two compressed camera streams), same code path on
both sides:
rows before after smaller
one shard, all topics 286 11.95 MB 11.30 MB 5.5%
one shard, IMU only 247 741.0 KB 124.2 KB 83.2%
one shard, cameras only 26 4.95 MB 4.92 MB 0.5%
one shard, include_metadata=False 286 11.24 MB 11.24 MB 0.0%
full recording, IMU only 21,300 63.90 MB 9.00 MB 85.9%
The saving tracks payload size against schema size, so it is large exactly
where robotics pipelines read high-rate, small-payload topics and negligible
where camera or lidar payloads outweigh the schema. Reading the recording
whole gains 5.5%; reading only its IMU gains 85.9%.
Only `schema_data` is encoded. `topic` and `schema_name` repeat as well, but
they are short enough that the saving is marginal, and callers sort and group
by them, which Arrow cannot do on a dictionary-encoded column.
Two costs, both verified rather than assumed:
- `sort_by("schema_data")` now raises `ArrowNotImplementedError`.
- `pa.concat_tables` refuses an encoded block beside an unencoded one, so
unioning a new read with a dataset cached before this change will fail.
Also expand the module docstring into a brief on the format: the record
layout, what a message, channel and schema each are, and the three
properties of MCAP this module's behaviour follows from -- the summary
section's position at the end of the file, schemas being stored once per
file, and chunks being the unit of compression.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces dictionary encoding for the schema_data column in the MCAP datasource to optimize memory usage when reading MCAP files with large schema definitions. It also adds comprehensive unit tests to verify the behavior. The reviewer suggested a performance optimization to reverse the order of combine_chunks() and dictionary_encode() on PyArrow columns to avoid the overhead of unifying dictionaries across multiple chunks.
Review suggested reversing the order to
`column.combine_chunks().dictionary_encode()`, on the grounds that
concatenating unencoded chunks is a fast buffer copy and encoding once
afterwards avoids unifying per-chunk dictionaries.
Measured, it is the other way round: the buffer being copied is the
repeated schema definitions, which is the redundancy this function exists
to remove. Encoding per chunk shrinks the data before anything is
concatenated.
rows chunks column encode-then-combine combine-then-encode
60,000 21 147.2 MB 10.2 ms / 160.7 MB 28.7 ms / 295.0 MB
5,000 2 12.3 MB 0.9 ms / 18.4 MB 1.1 ms / 24.6 MB
21,300 1 52.3 MB 3.6 ms / 86.4 MB 10.2 ms / 104.7 MB
Medians of five runs after a warm-up; peak is the process high-water mark
from `pyarrow.default_memory_pool()`. Results are byte-identical either
way, so this is purely a cost question.
Record it in a comment so the ordering is not re-litigated. Note also that
`combine_chunks()` is what collapses one dictionary per chunk into one for
the block; it is not required for correctness -- `pa.concat_tables` accepts
either form -- but it is close to free once the column is encoded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
A schema is a per-channel attribute in MCAP: the file stores each definition once in the data section, repeats it in the summary for lookup, and every channel that uses it refers to it by id.
MCAPDatasource._message_to_dictcopies the whole definition onto every row, so a block holds one value as many times as it has messages.Real ROS 2 definitions are not small. In the recording used below,
sensor_msgs/msg/Imuis 2,581 bytes andsensor_msgs/msg/CompressedImageis 1,505. On a high-rate topic with a small payload — IMU, odometry, transforms — that column dominates the block.Fix. Dictionary-encode
schema_dataonce per block, on the way out of_read_stream. Arrow keeps one copy of each distinct definition and gives every row an int32 index into it. The value a row reads back is byte-identical.Measurements
A FAST-LIVO ROS 2 recording — 105 s, 24,456 messages: Livox IMU at ~200 Hz, Livox lidar, and two compressed camera streams. Before and after go through the same code path (
read_mcap(...).materialize().size_bytes()), differing only in this change:include_metadata=FalseThe saving tracks payload size against schema size. It is large exactly where robotics pipelines read a high-rate, small-payload topic, and negligible where camera or lidar payloads outweigh the schema. Reading the recording whole gains 5.5%; reading only its IMU gains 85.9%, because 54.9 MB of that 63.9 MB was one 2,581-byte definition repeated 21,300 times.
include_metadata=Falseis byte-identical before and after, which is the column being absent rather than the encoding being skipped.Scope and costs
Only
schema_datais encoded.topicandschema_namerepeat as well, but they are short enough that the saving is marginal, and callers sort and group by them — which Arrow cannot do on a dictionary-encoded column. Encoding them would trade a real capability for a rounding error.Two costs, verified rather than assumed:
sort_by("schema_data")now raisesArrowNotImplementedError. Narrow, but real.pa.concat_tablesrefuses an encoded block beside an unencoded one, so unioning a new read with a dataset cached before this change fails. This is the argument for doing it in one step rather than behind a flag — a mixed corpus of blocks is the state worth avoiding.The helper leaves the column untouched when it is absent (
include_metadata=False), already encoded, all-null (no schema on any message), or of a type Arrow declines to encode.Also in this PR
The module docstring becomes a brief on the format: the record layout, what a message, channel and schema each are, and the three properties of MCAP that this module's behaviour follows from — the summary section sitting at the end of the file behind a footer offset, schemas being stored once per file, and chunks being the unit of compression. Each is load-bearing for code in the file rather than general background.
Related issues
Related to #65640 (file-level packed output), #65641 and #65787 (
batch_size) — none of which touches the row schema.Overlaps #65912 (in-memory size estimation) in two places, both small:
_read_stream, so once both are in it should also call_dictionary_encode_schema_datato keep its estimate faithful. Without that line the estimate overstates by roughly the saving above. I will add it in whichever of the two rebases second.Tests
20 passed, 1 failed. The failure is
test_read_mcap_invalid_time_range, which fails identically on unmodified master — itspytest.raisespattern omits the values the error message interpolates, so it can never match. It is fixed in #65912; I have left it alone here to avoid a second conflict between the two branches. Baseline on this branch point is 17 passed, 1 failed.Three new tests: the column is dictionary-encoded and every row reads back the original definition; the encoded block is smaller than the unencoded schema column alone would be; and
include_metadata=Falseomits the column with the encoding a no-op.Lint:
black,ruffclean.AI assistance (Claude) was used for this change. I reviewed every changed line and ran the tests locally.