Skip to content

[Data] Estimate MCAP in-memory size from sampled messages, not file size - #65912

Draft
marwan116 wants to merge 5 commits into
masterfrom
marwan/mcap-datasource-fixes
Draft

[Data] Estimate MCAP in-memory size from sampled messages, not file size#65912
marwan116 wants to merge 5 commits into
masterfrom
marwan/mcap-datasource-fixes

Conversation

@marwan116

Copy link
Copy Markdown
Contributor

Description

MCAPDatasource does not override estimate_inmemory_data_size(), so it inherits FileBasedDatasource's implementation, which sums on-disk file sizes. The value returned is not an approximation of the in-memory size — it is exactly the file size:

file size on disk       14,302 B
Ray's estimate          14,302 B
actual in memory     1,188,720 B

estimate == on-disk size : True
understated by           : 83x

Two effects compound. MCAP chunks are usually compressed, so the file is smaller on disk than in memory. And the datasource materializes per-message columns — topic, timestamps and, when include_metadata is set, the schema — that have no per-message on-disk equivalent at all. Ray Data derives both the read parallelism and the memory it provisions for the read from this number, so both are wrong for every MCAP read.

Fix. Follow the ParquetDatasource precedent: sample a bounded number of files (1% of the input, clamped to [2, 10], evenly spaced so size-ordered inputs don't bias the sample), read at most 100 messages from each, build a block through the datasource's own _message_to_dict + DelegatingBlockBuilder path, and scale the total on-disk size by the measured in-memory to on-disk ratio.

Accuracy after the change (actual / estimate), across shapes beyond the repro:

uniform 1.00 | varying payload 0.99 | 25 files sampled 1.30 | multi-topic 1.04
topic-filtered 1.02 | include_metadata=False 0.97 | include_paths=True 0.99
uncompressed 1.00

0.97x–1.30x, against 83x before.

Related issues

Related to #65640, #65641 and #65787 — none of them duplicates this. #65787 (batch_size) modifies __init__ and _read_stream only; #65640 and #65641 concern block granularity and batched reads. No open issue or PR covers estimate_inmemory_data_size for MCAP.

Additional information

Two MCAP-specific implementation details worth review.

Measure a materialized block rather than undoing compression. The chunk index reports a ~37x decompression ratio, but the true expansion is 83x — the gap is the per-row metadata columns. Building a real block keeps the estimate correct automatically as include_metadata, include_paths and filters change.

Open sampled files seekable. _open_input_source yields a non-seekable BufferedInputStream, which makes make_reader return a NonSeekingReader whose get_summary() rescans the whole file and is single-use. Sampling uses open_input_file so make_reader returns a SeekingReader and reads the summary from the footer index. Message counts come from statistics.message_count; a topic filter is resolved exactly via channel_message_counts.

Filters the summary cannot express. If the per-file iteration finishes before reaching the 100-message cap, it has seen the whole selection, so the measured size is used unscaled rather than extrapolated through the summary's message count. This matters because time_range and message_types are not representable in the summary. Without it, a time_range selecting 1% of a recording is estimated as if the whole file were selected:

              rows    before    after
no filter      360     1.0x     1.0x
first 50%      180     2.0x     2.0x   (exceeds the sample cap, still extrapolates)
first 10%       36    10.0x     1.0x
first  1%        4    90.0x     1.7x

Failure behaviour. Gated on DataContext.decoding_size_estimation. Every failure path falls back to a default ratio of 5 and logs a warning — over-estimating is the safe direction, consistent with the comment in _estimate_files_encoding_ratio, but a silent fallback would quietly restore the behaviour this change exists to replace.

Two bounded approximations remain, both erring high. Messages are sampled as a prefix of each file rather than spread through it, so a recording whose message size changes markedly from start to end is estimated from its opening; the sample across files is spread, so this is a per-file effect. And MCAP_ENCODING_RATIO_ESTIMATE_LOWER_BOUND floors the ratio at 1, which binds under a filter narrow enough that the selected rows are smaller than the file on disk — the 1.7x above.

Drive-by fix. test_read_mcap_invalid_time_range asserts on the regex "start_time must be less than end_time", but the code raises f"start_time ({self.start_time}) must be less than end_time ({self.end_time})". The pattern can never match, and the test fails on clean master. It is not caught in CI because mcap appears in no requirements file in the repo, so pytestmark = skipif(not MCAP_AVAILABLE) skips the entire module. That gap is worth addressing separately and I'm happy to file it; the one-line assertion fix is included here because this PR adds 10 tests to the same file.

Tests

PYTHONPATH=python/ray/data/tests python -u -m pytest \
    python/ray/data/tests/datasource/test_mcap.py -v

28 passed. Baseline on master is 1 failed, 17 passed.

New tests cover: estimate exceeds on-disk size, estimate tracks include_metadata, estimate respects a topic filter, multi-file aggregation, files with no summary statistics, sampling disabled via DataContext, include_paths (which adds a column downstream of _read_stream, so sampling has to add it too), a narrow time_range, log-time ordering (the fixture is written in descending log time, so passing requires the reader's sort rather than the file layout), and binary payload round-trip.

Lint: black, ruff clean on both changed files.

Self-contained repro (needs only ray[data] and mcap)
import os
import tempfile
from pathlib import Path

import ray
from mcap.writer import CompressionType, Writer

SCHEMA = b"uint8[] data\nstring format\n" * 100  # a realistic ros2msg definition

f = Path(tempfile.mkdtemp()) / "d1.mcap"
with f.open("wb") as fh:
    w = Writer(fh, compression=CompressionType.ZSTD)
    w.start(profile="", library="d1")
    sid = w.register_schema(
        name="foxglove.CompressedVideo", encoding="ros2msg", data=SCHEMA
    )
    cid = w.register_channel(topic="/cam", message_encoding="cdr", schema_id=sid)
    for i in range(360):  # 12 s of 30 fps video
        w.add_message(
            cid,
            log_time=i * 33_000_000,
            publish_time=i * 33_000_000,
            data=b"\x00\x00\x00\x01" + bytes([i % 256]) * 504,
            sequence=i,
        )
    w.finish()

ray.init(num_cpus=2, include_dashboard=False, log_to_driver=False, configure_logging=False)

from ray.data._internal.datasource.mcap_datasource import MCAPDatasource

estimate = MCAPDatasource(paths=[str(f)]).estimate_inmemory_data_size()
on_disk = os.path.getsize(f)
in_memory = ray.data.read_mcap(str(f)).materialize().size_bytes()

print(f"  file size on disk   {on_disk:>10,} B")
print(f"  Ray's estimate      {estimate:>10,} B")
print(f"  actual in memory    {in_memory:>10,} B")
print(f"  estimate == on-disk size : {estimate == on_disk}")
print(f"  understated by           : {in_memory / estimate:.0f}x")

AI assistance (Claude) was used for this change. I reviewed every changed line and ran the tests locally.

MCAPDatasource did not override estimate_inmemory_data_size(), so it
inherited FileBasedDatasource's sum of on-disk file sizes. The value
returned is not an approximation of the in-memory size, it is exactly the
file size: 14,302 B against 1,188,720 B materialized, an 83x
understatement on a 360-message recording. MCAP chunks are compressed,
and the datasource materializes per-message columns -- topic, timestamps
and, when include_metadata is set, the schema -- that have no per-message
on-disk equivalent. Ray Data derives both the read parallelism and the
memory it provisions for the read from this number.

Sample a bounded number of files (1%, clamped to [2, 10], evenly spaced
so that size-ordered inputs do not bias the sample), read at most 100
messages from each through the datasource's own conversion path, and
scale the total on-disk size by the measured in-memory to on-disk ratio,
following the ParquetDatasource precedent. Sampled files are opened
seekable so that make_reader returns a SeekingReader and reads the
summary from the footer index rather than rescanning the file.

When the per-file iteration finishes before reaching the message cap it
has seen the whole selection, so the measured size is used unscaled.
Without that, a time_range selecting 1% of a recording is estimated as if
the whole file were selected, since the summary cannot express a time
range.

Gated on DataContext.decoding_size_estimation. Every failure path falls
back to a default ratio of 5 and logs a warning, since a silent fallback
would restore the behaviour this change exists to replace.

Also fixes a pre-existing assertion in test_read_mcap_invalid_time_range
that can never match: the regex omits the values the error message
interpolates. It fails on clean master, and is not caught in CI because
mcap appears in no requirements file, so the whole module is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
@marwan116
marwan116 requested a review from a team as a code owner September 4, 2026 02:35

@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 implements in-memory size estimation for the MCAP datasource in Ray Data by sampling files and calculating an encoding ratio, addressing the issue where compressed MCAP files are significantly underestimated. It also adds comprehensive unit tests to validate this estimation logic. The review feedback correctly identifies a bug where an estimated size of zero is ignored due to a truthiness check, and suggests using an explicit is not None check instead.

Comment on lines +336 to +337
if in_memory_size:
ratios.append(in_memory_size / file_size)

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.

medium

Using if in_memory_size: will evaluate to False when in_memory_size is 0 (which is a valid estimated size when a filter selects zero messages). This causes the estimator to ignore valid zero-size estimates and potentially fall back to the default ratio of 5. Changing this to if in_memory_size is not None: correctly handles 0 as a valid size.

Suggested change
if in_memory_size:
ratios.append(in_memory_size / file_size)
if in_memory_size is not None:
ratios.append(in_memory_size / file_size)

@marwan116 marwan116 self-assigned this Sep 4, 2026
@marwan116 marwan116 added the go add ONLY when ready to merge, run all tests label Sep 4, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7419b3f. Configure here.

Comment thread python/ray/data/_internal/datasource/mcap_datasource.py
marwan116 and others added 2 commits September 3, 2026 19:48
`_estimate_file_inmemory_size` returns None when a file cannot be sampled
and 0 when the topic, time or message-type filter selects nothing from
it. The caller tested truthiness, so it discarded the measured zero
alongside the failures. A mixed input biased the mean ratio upward, and
an input where every sampled file is empty under the filter fell back to
the default ratio of 5 for a read that returns no rows at all.

The ratio lower bound still floors the result at 1, so the empty case now
estimates the on-disk size rather than five times it.

Reported independently by gemini-code-assist and Cursor Bugbot on #65912.

Also document the two properties of the MCAP format this module depends
on -- the summary section sitting at the end of the file, addressed by an
offset in the footer, and schemas being stored once per file rather than
once per message -- and link the format specification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
…count

The sample window is the first 100 messages a file yields, and its mean row
size was scaled by the file's whole message count. On multi-topic data that
makes the estimate depend on how many messages of each topic happen to land
in the window rather than on the file.

Measured on a 105-second FAST-LIVO ROS 2 recording (24,456 messages: a
200 Hz Livox IMU, a lidar, and two compressed camera streams), the window
spans 0.417s. The three heavy topics each hold a 4.3% share of the file, so
the window catches five of each where the file average is 4.3 -- one message
of quantization on the topics carrying nearly all of the bytes. That alone
put the estimate 18% over.

Weight per channel instead, using the exact per-channel counts the summary
already carries. Only a channel's row size is sampled, never the mix between
channels. Messages within a channel are near-uniform in size while channels
differ by orders of magnitude, so this removes the dominant error term.

    corpus                          truth      before     after
    8 zstd shards, 27x gradient     770.8 MB   +2.9%      +0.9%
    single 845 MiB recording        946.4 MB   +18.0%     +2.6%

The estimate is also no longer sensitive to the window size: per-channel
lands at 102.6% of truth for every window from 10 to 800 messages, where
scaling by the total count ranges from 228% to 104% across the same span.

A channel absent from the sample falls back to the mean row size across the
whole sample, and a summary carrying no per-channel breakdown falls back to
the previous whole-sample scaling.

Reported by a reviewer testing the branch against real ROS 2 recordings
rather than synthetic files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
marwan116 and others added 2 commits September 3, 2026 20:29
Two defects in the sampled estimate, both surfacing only when a filter
selects a small part of a file. A reviewer found them on a video-dominated
robotics episode; neither existing test could reach them.

The encoding ratio was floored at
MCAP_ENCODING_RATIO_ESTIMATE_LOWER_BOUND = 1. That guard exists so a
decompressed file is never reported as smaller than its compressed form.
Under a filter the quantity is not a decompression ratio at all -- it is
selected in-memory bytes over *total* on-disk bytes -- and on a recording
whose bytes are nearly all camera frames, reading only the state topics is
legitimately far below 1. The per-channel estimator computed the right
number and the clamp discarded it, reporting the whole file. Apply the
floor only when no filter narrows the read.

Separately, once sampling stopped at the message cap, the extrapolation
used whole-file per-channel counts. The summary cannot express a time
range, so a one-second window on a twenty-second recording was scaled by
twenty seconds of messages -- an error of exactly the ratio of the two
durations, and unbounded as windows shrink. Scale each channel's count by
the share of the file's span the window covers, which the summary does
carry as message_start_time/message_end_time. That assumes a roughly
constant publish rate per channel, which is what a sensor recording does.

Measured on a 21.8 MB episode (2 cameras of incompressible frames, 200 Hz
proprio, 50 Hz action), estimate over actual:

    no filter                    1.00x -> 1.00x
    state topics only           24.30x -> 1.00x
    state topics + 1 s window  485.90x -> 1.00x

A filter selecting nothing now estimates zero rather than the file's
on-disk size, completing the zero-sample fix in 195bee3: the floor was
what stopped the measured zero from being used.

New tests cover both, on a fixture with camera-dominated payloads and a
window above the sampling cap -- the regime the previous time-range test
could not reach, since its window selected fewer messages than the cap and
so was measured outright rather than extrapolated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
`MCAP_ENCODING_RATIO_ESTIMATE_LOWER_BOUND` was used only by the assertion
that a filter selecting nothing floors at the on-disk size. That behaviour
changed in the previous commit -- nothing selected now estimates zero -- so
the import went stale with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marwan Sarieddine <sarieddine.marwan@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant