Skip to content

[format][python] Compress video index pages and reduce seek I/O - #10905

Draft
XiaoHongbo-Hope wants to merge 8 commits into
apache:masterfrom
XiaoHongbo-Hope:codex/compressed-video-index-pages
Draft

XiaoHongbo-Hope wants to merge 8 commits into
apache:masterfrom
XiaoHongbo-Hope:codex/compressed-video-index-pages

Conversation

@XiaoHongbo-Hope

@XiaoHongbo-Hope XiaoHongbo-Hope commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Reduce video-index I/O with a page directory and optional compression.

  • Layout: index v1 adds a page directory and raw / column-delta zlib encodings. Video payloads and descriptors are unchanged.
  • Reads: cache small indexes; locate large-index pages through the directory without loading neighboring pages. Memory remains bounded.
  • Writes: video.index.compression defaults to delta-zlib; none disables compression. Readers auto-detect encoding; rewrites preserve it.

Measurements

Three-run medians. Local index-only test: 1M keyframes; writes include fsync, reads include open + 64 random lookups.

Input Encoding Size Write Read Bytes read
Regular Raw 24.02 MB 104.5 ms 11.8 ms 6.21 MB
Regular Delta-zlib 3.30 MB 214.4 ms 29.0 ms 0.88 MB
Irregular Raw 24.02 MB 103.1 ms 11.9 ms 6.21 MB
Irregular Delta-zlib 5.84 MB 293.2 ms 33.8 ms 1.52 MB

Compression saves I/O at a CPU cost. In three local DROID DataLoader runs (Python/PyAV, batch 32, 4 workers, 10 warmup + 64 measured batches), median throughput was 97.75 → 97.24 samples/s and CPU 5.76 → 5.75 cores; indexes shrank 90%. These indexes fit in cache; this does not measure large-index cache churn.

Separately, the directory-only OSS test reduced 64-lookup latency from 10.21 s to 4.02 s and GETs from 163 to 65.

Validation

Python: 190 passed, 1 skipped, 147 subtests; 6 additional decoder tests passed. Seven Ray tests excluded due to an incomplete local installation. Java: 26 passed.

Coverage includes cross-language fixtures, real video reads, mixed codecs, corruption, memory bounds, rolling and compaction. RAT, Maven checks, flake8, Python 3.6 syntax and diff checks passed locally.

@sundapeng

Copy link
Copy Markdown
Member

Thanks for the PR! The page directory looks like the right direction to me, and from my local tests most of the improvement comes from it. But I have some concerns about the format design, mainly the versioning and the compression part.

1. The v1 layout changes again without bumping the version

This is the third v1 layout within one month (#9831: <BQII + whole-index zlib, #10891: <BQIQ + fixed pages, and now <BQIQB + codec + directory), all with version byte 1. I tried to read an index written by current master (00387fb) with this PR, and also the reverse direction. Both fail with Video metadata range is outside the video payload, because every field after the header is shifted by one byte. The error message is quite misleading.

On the read path VideoKeyframeIndex.open just throws. On the copy path both Python (video_format_writer.py:214) and Java (VideoFormatWriter.java:336) validate the copied index, so compaction / rewrite will fail as well.

I know none of these commits is released yet, but people building from master may already have such data. Since we already have a version byte, I think we should just use version 2 here (it costs nothing), and let readers report "unsupported keyframe index version" instead of misparsing. Maybe also reserve a flags or header-length field, so next time we don't need to break the layout again.

2. Most of the gain comes from the directory, not compression

I counted GETs with an instrumented read_at (synthetic keyframes: constant frame rate, GOP 2, random packet sizes; 512 random preceding + keyframes[...] lookups):

master PR none PR zlib
65,536 keyframes, open 2 GET / 197 KB 1 GET / 1.57 MB 1 GET / 0.52 MB
65,536 keyframes, 512 lookups 346 GET / 76.9 MB 0 0
1M keyframes, 512 lookups 3,592 GET / 961 MB 487 GET / 47.7 MB 485 GET / 15.5 MB

Compression reduces the bytes by ~3x, but the GET count is the same. On OSS the latency is mostly decided by the number of requests; with a simple model (15 ms per GET + 100 MB/s, my assumption, not measured) it's about 7.8 s vs 7.4 s. For storage, the index is 24 bytes per keyframe while one keyframe usually covers tens of KB of video, so the saving is very small.

Also, once a codec is in the spec it's hard to change, and I'm not sure zlib over row-major int64 triples is a good choice. On the same data (GOP 2):

  • zlib level 1, row-major (this PR): 3.1x
  • DeltaVarintCompressor per column (we already have it in Java and Python for the .video footer): 4.0x
  • per-column delta + zlib level 1: 7.8x

So my suggestion is to split this PR: land the directory with codec 0 first, and discuss compression separately. If we keep compression, maybe define the codec as per-column delta encoding. It would also be great if the benchmark could show the directory and compression separately.

3. Unsupported version / codec cannot be distinguished from corruption

If we add codec 2 later, old readers will fail with Invalid video keyframe index header or codec, on both read and compaction. But the index is only an accelerator, and we already have the scan path when there is no index. How about:

  • unsupported version / codec: treat it as no index, log a warning, and copy it as is during compaction;
  • checksum or monotonicity failure: still fail.

4. Directory caches can be simpler (minor)

Python keeps three caches: directory blocks (512 records, LRU of 2), descriptors (LRU of 512) and pages. The directory for 1M keyframes is only 16.6 KB and is usually already in the 64 KB prefix. If we read the whole directory at open, the binary search needs no I/O and the first two caches can be removed.

I also left a few smaller comments inline (page size wording in the spec, the eager-loading threshold, and the temp file in the Java validator).

WDYT?

Comment thread docs/docs/concepts/spec/fileformat.md Outdated
Each page holds 4,096 uncompressed `(frame ordinal, PTS, packet position)` int64 triples
and their 4-byte CRC32; the last page may be shorter. Page `p` starts at
`21 + 16 * metadata_count + p * 98308`.
Each page holds up to 4,096 `(frame ordinal, PTS, packet position)` int64 triples,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This says "up to 4,096", but both readers assume every page except the last has exactly 4,096 entries: they compute the entry count as min(4096, count - p * 4096) and locate the page by divmod(index, 4096). So a writer which follows the spec and writes variable-size pages (e.g. a future Rust writer) will be rejected.

Maybe say "exactly 4,096 except the last page" here, or store page_entries in the header. The directory already has the first ordinal of each page, so variable-size pages are not hard to support.

prefix = read_at(0, min(length, cls.HEADER.size + _PagedKeyframes.PAGE_BYTES + 4))
if len(prefix) != min(length, cls.HEADER.size + _PagedKeyframes.PAGE_BYTES + 4):
# Small indexes use one request; larger indexes retain only a bounded prefix.
prefix_size = length if length <= _IndexedKeyframes.EAGER_BYTES else 64 * 1024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here the whole index is read when the encoded length <= 2 MiB, but load_small() only goes eager when the decoded size count * 24 <= 2 MiB. For compressed indexes in between, the whole encoded index stays in prefix, and pages are still decoded into the 1 MiB page cache. With 200K keyframes I see 1.58 MB held + 0.97 MB page cache, and it's not eager.

So the memory per open index goes from ~1.1 MB to ~2.5 MB, multiplied by max_open_videos (16 by default) and the number of DataLoader workers. Maybe use one threshold on the decoded size, or document this case.

crc.update(page, 0, bytes);
if (crc.getValue() != expected) {
throw invalid("page checksum");
Path path = Files.createTempFile("paimon-video-directory-", ".tmp");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This creates a temp file for every index only to read the directory twice. The caller's spool in VideoFormatWriter is already a RandomAccessFile, so maybe we can pass a seekable input here, or just keep the directory in memory. It is bounded by the index length, and only about 16.6 KB for 1M keyframes.

@XiaoHongbo-Hope XiaoHongbo-Hope changed the title [format][python] Compress video index pages and reduce seek I/O [format][python] Reduce video seek I/O with a page directory Oct 31, 2026
@XiaoHongbo-Hope XiaoHongbo-Hope changed the title [format][python] Reduce video seek I/O with a page directory [format][python] Compress video index pages and reduce seek I/O Oct 31, 2026
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.

2 participants