feat: component-wise and sharded dataset sources (npy + parquet, LAION-400M) - #161
Merged
Conversation
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>
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.
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.
.npydense vectors,.parquetpayloadsThe dataset layer assumed a dataset is a bundle (h5/tgz/csr: vectors, payloads and queries in one artifact). LAION ships
img_emb_*.npynext tometadata_*.parquet. The only.npyreader was buried in the tar format keyed off the literal namevectors.npy, and there was no parquet reader.Adds two component-scoped formats that a config pairs, row i of each landing on point i:
Parquet is read via the record API with
arrowoff — 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_nullreproducesupload.py'sdf.fillna(0).2. Sharded
parts:datasets410 files become one row space, so point ids stay global. This also turns
--offsetfrom 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.-nis 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_partoption 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 (.npyheader at the front, parquet footer at the back), so each part is sized with one ranged request and no download; results are cached indatasets/.parts-index/, keyed on the spec. A host that answers200to aRange:request is reported rather than silently streamed.3.
cache: evict— streaming a corpus larger than the diskEach 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.npyandmetadata_0.parquetboth report 1,000,448 rows; payloads decode to the expected string/int/float fields.cargo test143 passing, clippy andcargo +nightly fmt --checkclean.Not included
Query/ground-truth component sources, so
bfb searchcan report recall againstfull_scan.pyoutput — the accuracy path still only understands bundle formats. Worth a follow-up.🤖 Generated with Claude Code