File format#

A dataset is a directory (or bucket prefix) of tiles plus a small manifest. A tile is one self-contained object holding a block of consecutive cells (rows) of a count matrix in CSR form. All integers are little-endian; readers on big-endian hosts must refuse to load tiles (the reference implementation does).

Dataset layout#

<root>/
  manifest.json            required; written last, so a directory without it is an incomplete dataset
  tiles/tile_000000.dct    tile i = cells limits[i-1] : limits[i]   (zero-padded 6-digit index)
  tiles/tile_000001.dct
  ...
  gene_order.npy           optional; int32 array, output column j was column gene_order[j] of the source
  var_names.txt            optional; one gene name per line, in output (column) order
  var.parquet              optional; the gene table (one row per output column, in order; the pandas index is kept)
  obs/schema.json          optional; the obs schema (see "Obs" below)
  obs/obs_0000.parquet     optional; obs shards: up to obs_tiles_per_file tiles each, one row group per tile
  obs/obs_0001.parquet

Every tile except the last holds exactly tile_size cells; the last holds between 1 and tile_size. A global cell index c lives in tile searchsorted(limits, c, side="right") at local row c - (limits[tile - 1] or 0), where limits is the cumulative sum of tile_cells.

manifest.json#

JSON object with at least:

key

meaning

format

"deltacells-dataset"

version

2

n_cells, n_genes, tile_size

integers

tile_cells, tile_nnz, tile_bytes

per-tile lists (cells, stored nonzeros, file size in bytes)

n_chunks, zstd_level

encoder settings (informational)

has_gene_order, has_var_names, has_var_table

whether the optional files exist

has_obs

whether the dataset has obs; if so the next four fields are required

obs_tiles_per_file

tiles (row groups) per obs shard; shard s holds tiles s * K .. (s + 1) * K - 1

obs_shard_bytes, obs_shard_sha256

per-shard size and SHA-256 (hex)

obs_fingerprint

24 hex characters identifying the obs content (see below)

metadata

free-form JSON object

Readers must reject manifests whose format/version they do not understand or whose counts are inconsistent.

Tile layout#

offset 0                 header                64 bytes
offset 64                chunk table           n_chunks * 96 bytes
offset indptr_off        indptr                (n_cells + 1) * int64, 8-byte aligned
...                      streams               each compressed stream starts on an 8-byte boundary

Header (64 bytes)#

offset

size

field

notes

0

8

magic

DELTACEL

8

4

version

2

12

4

flags

must be 0 (readers reject unknown flags)

16

8

n_cells

24

8

nnz

stored nonzeros in the tile

32

4

n_genes

1 .. 2^31

36

4

n_chunks

40

8

indptr_off

multiple of 8, at or after the end of the chunk table

48

8

total_size

size of the whole blob; must equal the object size

56

4

crc32

CRC-32 (zlib / IEEE) of bytes [64, total_size)

60

4

reserved

0

Chunk table entry (96 bytes, n_chunks of them, starting at offset 64)#

cell_lo, cell_hi, nnz_lo, nnz_hi, gaps_off, gaps_len, vals_off, vals_len, vexc_off, vexc_len, gexc_off, gexc_len, each a uint64. The last four locate the chunk’s exception frames (see “Exceptions”); a length of 0 means the chunk has none (the offset is then ignored; writers store 0).

Chunks cover the tile contiguously: the first has cell_lo = nnz_lo = 0, every chunk starts where the previous one ended, and the last ends at n_cells / nnz. Chunk boundaries are chosen so that chunks hold roughly equal numbers of nonzeros, which makes them a convenient unit of parallel decoding. A tile with zero cells has zero chunks.

indptr#

int64[n_cells + 1], non-decreasing, indptr[0] = 0, indptr[n_cells] = nnz, and indptr[cell_lo] = nnz_lo, indptr[cell_hi] = nnz_hi for every chunk. The nonzeros of cell r are the entries indptr[r] : indptr[r+1] of the streams below. The indptr is stored uncompressed: it is 8 bytes per cell (0.2% of a typical tile).

Streams#

For each chunk with n = nnz_hi - nnz_lo nonzeros there are two streams, gaps and vals. Each is a single zstd frame (including its content size) that decompresses to exactly 2 * n bytes: the n uint16 values of the stream split into byte planes, i.e. all n low bytes followed by all n high bytes.

  • gaps: for each cell, the genes with nonzero counts in increasing order g_0 < g_1 < ...; the stored gaps are g_0, g_1 - g_0, g_2 - g_1, ... (the first gap of a cell is its first gene index, so cells decode independently). A gap above 65535 is stored as 65535 and is an exception.

  • vals: the counts, in the same order. Counts are integers in [1, 2^31 - 1]; zeros are never stored. A count above 65535 is stored as 65535 and is an exception.

A chunk with n = 0 still has two (tiny) valid frames.

Decoding a chunk: decompress both streams; for every cell in [cell_lo, cell_hi) take its slice [indptr[cell] - nnz_lo, indptr[cell + 1] - nnz_lo) and prefix-sum the gaps to get the gene indices; recombine the byte planes of the values; then apply the exception frames, if any. A decoder must verify that each frame decompresses to exactly 2 * n bytes and that every gene index it produces is < n_genes.

Exceptions#

The streams hold uint16 values, but real data has a few counts above 65535 (for example MALAT1 in some cells), and a dataset with more than 65536 genes has an occasional gap that large (a cell whose next expressed gene is far away in the gene order). They are rare, so instead of widening the streams for everyone they are listed beside them: a chunk that has any has an exception frame for its values (vexc) and/or one for its gaps (gexc). Each is a single zstd frame (including its content size) that decompresses to 8 * k bytes: k little-endian uint32 positions followed by k uint32 payloads. A position is the index of a nonzero within the chunk (0 .. n - 1); positions are strictly increasing, and k <= n.

  • vexc: the payload is the true count (65536 .. 2^31 - 1); it replaces the stored 65535.

  • gexc: the payload is the true gap minus 65535 (1 .. n_genes - 1); it is added to the gene index that the prefix sum gave for that nonzero and for every later nonzero of the same cell. The position tells which cell it is (the cell whose indptr range holds it). Several exceptions in one cell add up.

A decoder applies the exceptions after decoding the chunk, so chunks without any (nearly all of them) decode exactly as without this mechanism. It must check the frame size, the positions and payload ranges, and that no gene index reaches n_genes.

The reference writer refuses a tile with more than max(1024, nnz / 200) exceptions of either kind. That is a policy, not a format rule: exceptions are applied one by one (about 35 ns each), so a tile where they are not rare would decode slower, and data with so many counts above 65535 is not raw counts, which is what this format is for.

Obs (optional per-cell metadata)#

Obs is deliberately not part of the tiles: a worker that needs no metadata never pays for it, and tiles stay small and quick to decode. The obs table is stored beside them so that obs row group i always describes the cells of tile i.

Shards. Parquet files obs/obs_NNNN.parquet (4-digit index). Shard s holds the tiles s * K .. min((s + 1) * K, n_tiles) - 1 (K = obs_tiles_per_file, default 1000) with exactly one row group per tile, in tile order, and each row group has exactly tile_cells[t] rows. zstd compression; column statistics are not needed (readers never push predicates down) and are best left out, since they dominate the footer. Row order within a tile is the cell order of the tile.

Schema (obs/schema.json): {"format": "deltacells-obs", "version": 1, "columns": [...]}, each column {"name", "kind", "arrow_type", "categories"}:

kind

stored as

notes

category

integer codes (arrow_type int8 / int16 / int32 by vocabulary size)

code i means categories[i]; code -1 means missing. The vocabulary is global: the union over all cells, so codes mean the same thing in every tile.

numeric

the Arrow type named in arrow_type (e.g. int64, double, float = float32)

missing values are nulls

bool

Arrow bool

missing values are nulls

string

Arrow string

also used for categorical columns whose vocabulary exceeds the writer’s max_categories (default 20,000), e.g. cell barcodes

The parquet files contain exactly the schema’s columns, in order, with the Arrow types named in the schema.

Fingerprint. obs_fingerprint is the first 24 hex characters of the SHA-256 of the canonical JSON (sorted keys) of {"schema": <schema.json text>, "tile_cells": [...], "shards": [<sha256 of each shard>]}. It changes whenever the obs content does, which is what keys the local cache.

Local cache (non-normative; what the reference implementation does). Readers never read obs during training. The first time columns are requested they are localized: for each requested column only its column chunks are read from the shards (ranged reads of the footer and of the column chunks of each row group) and written to <cache>/<fingerprint>/obs/<url-quoted column name>.arrow, an uncompressed Arrow IPC file with one record batch per tile, which is then memory-mapped. Columns are added independently and atomically (temporary file, then rename) under a lock file, so concurrent processes on a node fetch each column once. <cache> is, in order, the cache_dir argument, $DELTACELLS_CACHE, $XDG_CACHE_HOME/deltacells, ~/.cache/deltacells. A cached file whose record-batch count or row counts disagree with the manifest is deleted and fetched again.

var.parquet. The gene table in output column order (the writer applies gene_order), with the original pandas index.

Why this layout#

  • One object per tile means one GET per tile on object storage, and no global index to cache: everything needed to decode a tile is inside it.

  • Genes sorted by decreasing total counts (done by the converter, recorded in gene_order.npy) makes the gaps small and repetitive; delta coding exploits that; byte planes put the almost-always-zero high bytes together; zstd at a high level squeezes the result (higher levels are smaller and, here, decode faster – they only cost compression time).

  • Independent chunks let one tile be decoded by several threads at ~1% size cost for 8 chunks.

Limits (version 2)#

  • at most 2^31 genes and counts in [0, 2^31 - 1] (decoded indices and counts are int32); counts above 65535 and gaps above 65535 are exceptions and must be rare (see “Exceptions”); float32 values are exact only up to 2^24;

  • a chunk has fewer than 2^32 nonzeros (exception positions are uint32; use more chunks otherwise);

  • a tile’s nnz is bounded only by memory (offsets are 64-bit);

  • no per-cell metadata in the tile itself (see “Obs” above for the sidecar);

  • obs columns are limited to categorical, numeric, boolean and string; other pandas dtypes (e.g. datetimes) must be excluded or converted.