Skip to content

rldb/lance: stream frames from MP4 byte ranges, drop chunk tables - #730

Draft
RyanPCo wants to merge 1 commit into
ryanco/eva-to-lancefrom
ryanco/lance-mp4-ranges
Draft

RyanPCo wants to merge 1 commit into
ryanco/eva-to-lancefrom
ryanco/lance-mp4-ranges

Conversation

@RyanPCo

@RyanPCo RyanPCo commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

LanceStreamingDataset now reads frames straight from the MP4s: each
process reads an MP4's sample table once (one range GET of its moov),
then a batch fetches only the packets its frames need (keyframe through
frame; adjacent with GOP 2) in one concurrent round of range GETs and
decodes them in memory with PyAV. Frames match torchcodec bit for bit.

On R2 with 8 workers this ties the K=8 chunk tables on raw reads (277
vs 276 samples/s steady state) and runs 5-15% behind through the full
loader, with video stored once and nothing for the committer to build.
So video_chunks tables, chunk_videos.py and --chunk-k go. The chunk path
had been broken since leaves were keyed by recording_id (row.episode_hash).

https://claude.ai/code/session_01HjCQBqQCr2igFEMR7yUovw

@RyanPCo
RyanPCo force-pushed the ryanco/eva-to-lance branch from e642184 to bd04b38 Compare October 22, 2026 14:06
@RyanPCo
RyanPCo force-pushed the ryanco/lance-mp4-ranges branch from 8ae0806 to 0b25bc8 Compare October 22, 2026 14:06
@RyanPCo
RyanPCo force-pushed the ryanco/eva-to-lance branch from bd04b38 to 204c215 Compare October 23, 2026 19:28
@RyanPCo
RyanPCo force-pushed the ryanco/lance-mp4-ranges branch from 0b25bc8 to 07c145d Compare October 23, 2026 19:28
@RyanPCo
RyanPCo force-pushed the ryanco/eva-to-lance branch from 204c215 to f5b66c3 Compare October 23, 2026 19:36
@RyanPCo
RyanPCo force-pushed the ryanco/lance-mp4-ranges branch 2 times, most recently from 12ee83c to ffa99f1 Compare October 23, 2026 20:03
@RyanPCo
RyanPCo force-pushed the ryanco/eva-to-lance branch from f5b66c3 to ca35d13 Compare October 23, 2026 20:03
@RyanPCo
RyanPCo force-pushed the ryanco/lance-mp4-ranges branch from ffa99f1 to 4e434b9 Compare October 30, 2026 18:27
@RyanPCo
RyanPCo force-pushed the ryanco/eva-to-lance branch from ca35d13 to 64165e7 Compare October 30, 2026 18:27

@RyanPCo RyanPCo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated adversarial review (read-only; findings were checked by a second skeptical pass before posting): 4 low, 3 nit.

return parse_index(pre + moov, (len(pre) + hlen, len(pre) + size))
if typ == b"mdat" and not head:
head = buf[: pos - base] if base == 0 else b""
pos += size

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

low (correctness): read_index does not handle a box with size == 0 (runs to EOF), which _boxes does handle. For a non-moov box, pos += 0 loops forever with no further read, so a DataLoader worker hangs on a malformed or streamed MP4 instead of raising. Raise a ValueError when size == 0 or size < hlen, and raise a clear 'no moov before EOF' error when read returns fewer than 8 bytes, instead of letting struct.error escape.

leaves = LanceStreamingResolver(
root, video_chunks={}, key_map=KEY_MAP, cameras={}
).resolve()
leaves = LanceStreamingResolver(root, key_map=KEY_MAP, cameras={}).resolve()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

low (test-quality): Every streaming test here passes cameras={}, so _fetch_range_frames returns early, and the new batched range-fetch path (URL grouping, the num_frames check, merged-range jobs, per-sample decode) has no unit coverage. The only end-to-end check is skipped unless EGOVERSE_TEST_LANCE_ROOT is set. Consider writing a small AV1 MP4 into the fixture catalog with fixtures.videos.write_av1, then asserting that a streaming leaf with a camera key matches the local leaf, including two samples that share a URL.

def __init__(self, recs, images):
self.recs, self.images = recs, images
self.timing = {"take": 0.0, "blobs": 0.0, "decode": 0.0}
def _read(url: str, a: int, b: int) -> bytes:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

low (duplication): _read repeats dataset.read_range from this PR line for line, and RangeDS._decoder repeats dataset._mp4 without the LRU bound. Import read_range and _mp4 from egomimic.rldb.lance.dataset so the bench times the same read and index code the loader uses and can't drift from it.

return parse_index(pre + moov, (len(pre) + hlen, len(pre) + size))
if typ == b"mdat" and not head:
head = buf[: pos - base] if base == 0 else b""
pos += size

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

low (correctness): Unlike _boxes, read_index doesn't handle size == 0. A top-level box with size 0 before moov (for example an mdat from a non-seekable muxer) makes pos += size add nothing, and the loop spins forever, hanging the worker. A file with no moov fails with a bare struct.error or an S3 range error. Raise ValueError("moov not found before EOF") when size is 0 or the read comes back short.

def __init__(self, recs, images):
self.recs, self.images = recs, images
self.timing = {"take": 0.0, "blobs": 0.0, "decode": 0.0}
def _read(url: str, a: int, b: int) -> bytes:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit (simplification): _read duplicates egomimic.rldb.lance.dataset.read_range, which this PR adds (local seek/read, or S3 get_object with an inclusive Range). Import read_range and delete _read so the benchmark measures the same code path the loader uses.

Comment thread egomimic/rldb/lance/mp4_ranges.py Outdated
pos += size


def ranges(index: Mp4Index, frames, gap: int = 0) -> list[tuple[int, int]]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit (dead-code): No caller passes gap; the loader and the bench both use 0. Drop the parameter and merge on a <= out[-1][1], or set it from a named constant in _fetch_range_frames if near-adjacent merging is meant for S3.

Lance writer. As conversions finish it validates them (lance_pilot/commit.py), appends the
recordings' video chunk rows when --chunk-k is set, then commits them in batches. Reruns
Lance writer. As conversions finish it validates them (lance_pilot/commit.py), then
commits them in batches. Reruns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit (docs): Reflow this paragraph. Removing the chunk sentence left a ragged break after "Reruns".

LanceStreamingDataset now reads frames straight from the MP4s: each
process reads an MP4's sample table once (one range GET of its moov),
then a batch fetches only the packets its frames need (keyframe through
frame; adjacent with GOP 2) in one concurrent round of range GETs and
decodes them in memory with PyAV. Frames match torchcodec bit for bit.

On R2 with 8 workers this ties the K=8 chunk tables on raw reads (277
vs 276 samples/s steady state) and runs 5-15% behind through the full
loader, with video stored once and nothing for the committer to build.
So video_chunks tables, chunk_videos.py and --chunk-k go. The chunk path
had been broken since leaves were keyed by recording_id (row.episode_hash).

Claude-Session: https://claude.ai/code/session_01Kt1E8T2x8kdV9akAvbJyVd
@RyanPCo

RyanPCo commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Review findings: status after the fixup pass. The fixes are squashed into this PR's commits, and the whole stack was force-pushed.

Fixed

  • read_index loops forever on a box with size 0 (extends to EOF): read_index raises ValueError for size < header (incl. 0) and for EOF before moov; added a test with synthetic files
  • New _fetch_range_frames batching path has no unit-test coverage: Added AV1 videos to the catalog. Tests check that streaming frames match a full PyAV decode (torchcodec patched out), that each MP4 index is read once for samples sharing a URL, and that a frame-count mismatch is rejected
  • _read duplicates dataset.read_range: bench imports read_range from dataset; _read deleted
  • read_index loops forever on a size-0 box and has an unclear failure when there is no moov: Same fix as the other read_index finding: clear ValueError instead of a hang or struct.error
  • gap parameter of ranges() is never used: Dropped the gap parameter

Partial

  • bench_ranges re-implements read_range and the per-process MP4 decoder cache from dataset.py: _read and _decoder replaced by dataset.read_range and dataset._mp4. The jobs/fetch/decode loop stays because the bench times each stage separately

Invalid

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.

1 participant