Skip to content

feat: component-wise and sharded dataset sources (npy + parquet, LAION-400M) - #161

Merged
generall merged 3 commits into
devfrom
feat/component-and-sharded-datasets
Jul 26, 2026
Merged

feat: component-wise and sharded dataset sources (npy + parquet, LAION-400M)#161
generall merged 3 commits into
devfrom
feat/component-and-sharded-datasets

Conversation

@generall

Copy link
Copy Markdown
Member

Lets bfb upload corpora published as separate files per component and as numbered parts — LAION-400M being the motivating case, which previously had no route in at all.

Three commits, each independently useful:

1. .npy dense vectors, .parquet payloads

The dataset layer assumed a dataset is a bundle (h5/tgz/csr: vectors, payloads and queries in one artifact). LAION ships img_emb_*.npy next to metadata_*.parquet. The only .npy reader was buried in the tar format keyed off the literal name vectors.npy, and there was no parquet reader.

Adds two component-scoped formats that a config pairs, row i of each landing on point i:

vectors:
  - size: 512
    source: { type: dataset, name: emb, format: npy, path: emb.npy }
payload:
  source:
    type: dataset
    dataset: { name: meta, format: parquet, path: meta.parquet, exclude: [exif] }

Parquet is read via the record API with arrow off — payload rows need no columnar machinery, and skipping it keeps the dependency tree an order smaller. Access is a streaming cursor plus a ring of recent rows rather than a decode-the-file cache: materializing a LAION metadata part up front would cost hundreds of MB before the first batch. Values with no JSON form (null, NaN, ±inf) leave the field absent; fill_null reproduces upload.py's df.fillna(0).

2. Sharded parts: datasets

parts:
  count: 410
  path: laion/img_emb_{i}.npy
  link: https://…/img_emb_{i}.npy

410 files become one row space, so point ids stay global. This also turns --offset from a hazard into a resume switch: a point's id is its dataset row, so per-part invocations used to run off the end of a part. -n is now capped by the rows remaining, and an offset past the end errors rather than uploading nothing.

Row counts are measured, never configured. A rows_per_part option was in the original design and got cut once the real files were probed — LAION has seven distinct part sizes, and the first short one is part 8, so a plausible-looking fixed size would silently pair payloads with the wrong vectors across ~98% of the corpus. Both formats keep their shape at a known end of the file (.npy header at the front, parquet footer at the back), so each part is sized with one ranged request and no download; results are cached in datasets/.parts-index/, keyed on the spec. A host that answers 200 to a Range: request is reported rather than silently streamed.

3. cache: evict — streaming a corpus larger than the disk

Each part is deleted once the reader passes it, and the next is prefetched in the background while the current one uploads. Peak disk ~4 GB instead of ~600 GB. Two safety rules: only parts bfb downloaded itself are ever deleted (a hand-staged file is never touched), and a part is unlinked only once no reader still references it — the parquet reader reopens its file by path on a rewind.

Verification against the published LAION files

  • img_emb_0.npy and metadata_0.parquet both report 1,000,448 rows; payloads decode to the expected string/int/float fields.
  • All 820 parts size in ~1.5 minutes over the network, downloading nothing; the vector and payload families agree exactly at 407,314,954 rows.
  • Part size distribution: 404 × 1,000,448, one × 1,000,501, and short parts 8 (642,675), 107 (189,159), 220 (440,869), 319 (342,038), 409 (518,720).

cargo test 143 passing, clippy and cargo +nightly fmt --check clean.

Not included

Query/ground-truth component sources, so bfb search can report recall against full_scan.py output — the accuracy path still only understands bundle formats. Worth a follow-up.

🤖 Generated with Claude Code

generall and others added 3 commits July 26, 2026 14:24
The dataset layer assumed a dataset is a *bundle* — one artifact holding
vectors, payloads and queries together, which is what h5/tgz/csr are. Corpora
published as separate files per component (LAION-400M ships `img_emb_*.npy`
alongside `metadata_*.parquet`) had no way in: the only `.npy` reader was
buried inside the tar format, keyed off the literal name `vectors.npy`, and
there was no parquet reader at all.

Add two component-scoped formats, `npy` (dense vectors only) and `parquet`
(payload rows only). The upload config already keeps a separate source per slot,
so pairing them needs no new plumbing — row *i* of each file lands on point *i*:

    vectors:
      - size: 512
        source: { type: dataset, name: emb, format: npy, path: emb.npy }
    payload:
      source:
        type: dataset
        dataset: { name: meta, format: parquet, path: meta.parquet, exclude: [exif] }

The `.npy` reader moves out of `tar.rs` into `readers/npy.rs`, shared by both
formats. Its header parser now accepts a short prefix of the file rather than
the whole mapping, so a row count can later be had from a ranged request.

Parquet is read through the record API with `arrow` deliberately off — payload
rows need no columnar machinery, and skipping it keeps the dependency tree an
order smaller. Access is a streaming cursor plus a ring of recent rows, not a
decode-the-file cache: upload walks ids in order, and materializing a whole
LAION metadata part up front would cost hundreds of MB before the first batch.
Reads behind the ring rewind only to the containing row group.

Values with no JSON form — nulls, NaN, ±inf, non-UTF-8 bytes — leave the field
absent by default; `fill_null` substitutes a value instead, matching what the
reference `upload.py` gets from `df.fillna(0)`. `columns`/`exclude` project at
the parquet level, so dropping `exif` also skips decoding it.

Boxing `DatasetConfig` at its three embedding sites keeps the config enums from
being sized by a variant that only appears in dataset-backed runs.

Verified against the published LAION files: `img_emb_0.npy` and
`metadata_0.parquet` both report 1,000,448 rows, and payload rows decode to the
expected string/int/float fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpora too large to publish as one artifact ship as numbered parts, and there
was no way to point a source at more than one file. Uploading LAION-400M meant
409 separate invocations, each of which would then have collided on point ids:
a point's id doubles as its dataset row, so `--offset` shifted the row index
too and reading part *k* at offset *k*×1M ran off the end of that part.

Add a `parts:` block to dataset sources — `count`, optional `start`, and `{i}`
templates for `path`/`link` — presenting the family as one contiguous row space.
Point ids stay global across the whole corpus, and `--offset` becomes a resume
switch rather than a hazard: it skips rows and ids together, and `-n` is now
capped by the rows *remaining* instead of the corpus size (an offset past the
end is an error rather than a silently empty run).

Row counts per part are measured, never configured. A `rows_per_part` setting
would have looked reasonable and been wrong: LAION's parts are 1,000,448 rows,
except part 408 at 1,000,501 and part 409 at 518,720, so a fixed guess
misaligns payloads against vectors near the end and silently drops ~700k
points. Both formats keep their shape at a known end of the file — the `.npy`
header at the front, the parquet footer at the back — so each part is sized
with one ranged request and no download. `TailChunkReader` presents a fetched
suffix to the parquet metadata reader as if the whole file were there.

Sizing results are cached in `datasets/.parts-index/<name>.json`, keyed on a
hash of the parts spec so a changed spec re-measures. Rather than validating
the cache with per-run ETag requests, each part's real row count is checked
against the manifest when it is opened — free, since opening reads it anyway,
and it catches the stale-sidecar case at the moment it would corrupt ids.

A host that answers 200 to a `Range:` request is reported, not silently
streamed: the failure mode this guards against is downloading 600 GB to compute
410 integers.

Verified against the published LAION files: sizing 5 `img_emb_*.npy` and 5
`metadata_*.parquet` parts takes ~4s over the network, downloads nothing, and
both families report an identical 5,002,240 rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With `parts:`, a run downloads each part as it reaches it and keeps every one —
so uploading LAION-400M needs ~600 GB of scratch space for a corpus that is
only ever read once, front to back. The reference `upload.py` avoids this by
fetching a part, uploading it and deleting it; bfb had no equivalent.

Add `cache: keep | evict` to sharded sources. Under `evict`, a part is deleted
once the reader has moved past it, holding peak disk to the few parts in flight
(~4 GB for LAION rather than ~600 GB). Two rules keep that safe:

  * only parts bfb downloaded itself are ever deleted, tracked as they are
    fetched — a file staged in the datasets dir by hand is never touched, since
    bfb may have no way to get it back;
  * a part dropped from the LRU is deleted only once no reader still references
    it. The parquet reader reopens its file by path when a read rewinds, so
    unlinking under a live reader would break it; pending evictions hold a
    `Weak` and are swept when the last reference goes.

Opening a part now also starts fetching the next one in the background, so
crossing a part boundary does not stall the upload on a ~1 GB download. Each
part gets its own download lock, so a prefetch of part n+1 neither duplicates
nor blocks a reader that wants part n; whoever arrives second waits and finds
the file already there.

`cache: evict` is rejected on a non-sharded dataset rather than accepted as a
no-op — it is a setting about deleting files, and silence would be the wrong
default there.

Sizing the real corpus turned up a sharper argument for measuring part rows
than the one in the previous commit. LAION-400M has seven distinct part sizes,
not two: 404 parts of 1,000,448 rows, one of 1,000,501, and five short parts —
8, 107, 220, 319 and 409 — holding 189,159 to 642,675 rows. The first short one
is part *8*, so a plausible-looking fixed size would have misaligned payloads
against vectors across ~98% of the corpus and overcounted it by 2.9M rows. The
docs previously claimed only the tail was irregular; corrected here. All 820
parts size in ~1.5 minutes, and both families agree at 407,314,954 rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@generall
generall merged commit 6674747 into dev Jul 26, 2026
5 checks passed
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