Contents
sift_col shredder: jsonb to typed columns
The shredder turns a jsonb document into typed column values for a list of declared paths. src/col/shred.[ch] (portable, src/portable-col.list) is the only code that knows JSON; src/col/shred_pg.[ch] drives it from a JsonbContainer. The storage and the planner see typed arrays, element offsets and per-value states.
Declaring paths (reloption paths)
paths = 'attributes->locale text, metrics.* float4,
items[]{category text, sku text, kind text, qty int4, price float4}, notes[]{type text, author_kind text}'
spec := item { ',' item }
item := path ( type | '{' item { ',' item } '}' ) -- a group is relative to the element its path ends with
path := step { sep step } sep := '.' | '->' -- the two separators are the same
step := key | key '[]' { '[]' } | '*'
key := bare ([A-Za-z0-9_$] and bytes >= 0x80) | "quoted" ("" is a double quote)
type := bool | int2 | int4 | int8 | float4 | float8 | text [ '(' N ')' ] | astext [ '(' N ')' ]
Aliases: boolean, smallint, int, integer, bigint, real, double [precision]. A path starts with a key. key[] is an array
of the following element (tags[] text: an array of scalars; items[].sku text or items[]{sku text}: an array
of objects; matrix[][] float4: an array of arrays of scalars). * is every member of an object (metrics.* float4; stats.*{n int4, avg float8}
members that are objects). A wildcard cannot share its object with explicit keys. A path may be declared once; items that share a
prefix share the node (a.b int4, a.c text, a[].x int4, a[].y text). Errors name the position
(ERRCODE_SYNTAX_ERROR) or the conflict (ERRCODE_INVALID_PARAMETER_VALUE, ERRCODE_PROGRAM_LIMIT_EXCEEDED).
sift_shred_spec_deparse gives the canonical form, one flat item per column in declaration order (attributes.locale text,
metrics.* float4, items[].category text, ...), always with .; fingerprint is the sift_term_hash64 of the canonical text; a segment stores both, so a
changed spec is told from the one the segment was built with. Limits: 256 columns, 64 groups, 16 steps per path, 64 KiB of spec.
sift.suggest_paths(table, column, sample => 2000) writes this spec from the documents of a jsonb column (advice only; NULL when no key qualifies). The documents come from 16 stretches of blocks evenly spaced over the table. A table of another access method (a sift_table) has no blocks to space: its first documents, up to the sample, are read in one scan. A key becomes a typed path when most of its values are one scalar type (astext for a mix of numbers, booleans and text); a map of more than 4 keys of one type becomes a wildcard (it serves jsonb_each and every named key); an array of objects becomes a group (the root of a document may be that array). A key in fewer than 1% of the documents, without a single shape, or a text whose values average more than 64 bytes (free text is no filter or group key) is left out. It refuses a table the caller cannot read and one with row-level security.
An index with a jsonb column and no paths option (or paths = 'auto') takes that answer at CREATE INDEX: the build reads the sample, writes the spec to the option as auto: spec (so the build, its workers and every later REINDEX share one schema; pg_dump restores it as written) and says so in a NOTICE. Paths written by hand are never touched. When no key qualifies (an empty table, documents without a repeated scalar key) nothing is written and the column is refused. To read the documents again, ALTER INDEX ... SET (paths = 'auto') (or RESET (paths)) and REINDEX at once: as after any change of paths the index has no layout between the two, and a write to the table fails until the REINDEX (col_paths_auto).
Output model (Dremel / DuckDB LIST)
A group is a repeated level: the root (exactly one element, the document), every [] and every *. Every column (a typed
leaf) belongs to one group and has one slot per element of that group, in document order:
| per column | meaning |
|---|---|
state[i] |
ABSENT (path missing or a scalar/null on the way), NULL (JSON null), VALUE, OVERFLOW |
val[i] |
8 bytes: i (bool 0/1, int2/4/8), f (float4 is the exact float4 widened to double, float8), s.off/s.len into doc.text (text, UTF-8, no NUL) |
ovf |
OR of the SIFT_SHRED_OVF_* reasons seen in the document for this column, also from lists below that overflowed |
| per group | meaning |
|---|---|
n |
number of elements in the document |
end[p], lstate[p] |
for each element p of the parent group: the elements of this group that belong to p are [end[p-1], end[p]) (end[-1] = 0), and the state of the list itself: ABSENT (key missing), NULL (JSON null), VALUE (an array / object, possibly empty), OVERFLOW (not a list, or cut short) |
keyid[i] |
maps only (*): the dictionary id of the member name |
Elements keep their array position: a scalar, null or nested array inside an array of objects is an element whose columns are
ABSENT (scalar, null) or OVERFLOW (array), so offsets equal jsonb array indexes. Correlation is by construction: all columns of a
group at element i come from the same JSON object, so EXISTS (one element with category = x AND kind = y) is a scan of the
group’s slots i and never mixes elements; sift_shred_expand_parents gives the per-element parent ordinal when a flat parent column
is easier. Nested groups chain through the same end arrays.
Absent versus null, in SQL terms: doc #>> path IS NULL is ABSENT or NULL; doc #> path is a JSON null (jsonb_path_exists, the ?
operator) for NULL and a missing member for ABSENT; an empty array is a list that is VALUE with no elements, a missing or null array is
ABSENT or NULL.
Order is stable and deterministic: columns in declaration order, elements in array order, map members in jsonb storage order (key
length, then bytes), dictionary ids in order of first appearance across the documents shredded through one SiftShred.
Wildcard dictionary
* members are (keyid, value) pairs of a map group. Ids are the codes of a col-core SiftCDict (col/dict/dict.c) created with the
shredder (sh->dict): first-seen order, sift_cdict_str gives the bytes of an id. A key over 255 bytes (SIFT_COL_DICT_MAX_ENTRY), or beyond max_keys
(default 65536), gets SIFT_SHRED_NO_KEY and an OVERFLOW value: the member is still counted, never silently dropped.
Types and the overflow rule
The shredder never errors on data and never converts to fit: a value that is not exactly representable in the declared type is
recorded as OVERFLOW (with a reason) and the column is flagged, so a consumer must not prune with a zone map or a presence bitmap
of that column for that block, and recheck the row from the heap. Elements already emitted are correct; an OVERFLOW list is
incomplete or unrepresentable.
| declared | JSON | result |
|---|---|---|
| int2 / int4 / int8 | number with an integral value in range (3, 3.0, 1e2) |
VALUE |
3.5, out of range |
OVERFLOW RANGE (SQL would round with the jsonb cast and fail with the text cast) |
|
| float4 / float8 | any number whose conversion does not overflow | VALUE: the correctly rounded value of the decimal text, i.e. strtof / strtod, which is what float4in / float8in do, hence equal to (doc #> path)::float4, (doc #>> path)::float4, and to jsonb_to_recordset |
rounds to infinity (1e400) or to zero from a non-zero value (1e-400) |
OVERFLOW RANGE (PostgreSQL raises “out of range”); denormals are values |
|
| bool | true / false |
VALUE |
| text | string, at most N bytes |
VALUE |
| longer | OVERFLOW LENGTH |
|
| astext | a string (as it is), a number (the text numeric_out prints, which is what ->> gives: 1.50 stays 1.50, 1e2 is 100), true, false; at most N bytes like text |
VALUE |
| an object or array, longer | OVERFLOW SHAPE, LENGTH |
|
| any | JSON null | NULL |
| any scalar type but astext | a different JSON scalar (string in a number column, number in a text column, …) | OVERFLOW TYPE (no coercion: ->> would give '12' for the number 12) |
| any scalar type | an object or array | OVERFLOW SHAPE |
| object position | array | all columns below OVERFLOW SHAPE (lax jsonpath would unwrap it) |
| object position | scalar, null | columns below ABSENT |
| list position | an object / scalar (lax jsonpath would wrap it) | list OVERFLOW SHAPE, columns below flagged |
list over max_elems / max_bytes |
the rest of the list is dropped, list OVERFLOW TRUNC, columns below flagged |
The type of an overflow value. An OVERFLOW slot also keeps the JSON type of the value in tag[i] (SIFT_COLTAG_STRING, NUMBER, BOOLEAN, OBJECT, ARRAY: the event that delivered it, so a number out of range is a number and a string in a number column is a string). A leaf below the array that sits at an object position has SIFT_COLTAG_NONE: its path leads to no value, as for ->. Where the leaf is not what the event delivered, and for what a writer cannot know (a heap column, a sift_table segment), the tag is UNKNOWN (0). The writers store the tags of overflow slots in the chunks of column format minor 4; jsonb_typeof(path) is answered from them (docs/COL-PLAN.md, “jsonb_typeof”). A NULL slot has SIFT_COLTAG_JNULL (7), a JSON null as against a missing key; the VALUE of an ASTEXT leaf has the tag of the value it was (NUMBER, BOOLEAN; a string and the other leaf types have 0); ABSENT has none.
A slot that is not VALUE has a zero value that means nothing: NULL is never a sentinel, the states (or state_bits) say which slots are NULL. A codec that takes the int64 keys of a column plus a NULL bitmap needs nothing else from the shredder (integers are the keys as they are; floats are exact doubles for the engine’s order keys; text is bytes for its dictionary; map key ids and parent ordinals are plain integers, end[] is non-decreasing).
Negative zero is stored as +0 (numeric has none), so no NaN and no -0 is ever produced. Integer sums, min, max, count over
VALUE slots are exact. Float aggregates equal plain SQL when the aggregator uses the same transition functions in the same
order (float4pl in float4, float8pl in float8; a float4 column summed in float8 differs from sum(float4) exactly as in SQL).
Numbers arrive as the decimal text of the jsonb numeric (numeric_normalize: no exponent, no trailing fraction zeros). Integers are parsed from the digits, exact up to
19 digits; floats go through strtof / strtod (the process keeps LC_NUMERIC at “C”, as PostgreSQL does), with a shortcut for
integers that are exact in the type.
jsonpath !=, !() and @> with a JSON null (tests in the fixtures jp_*, t_path_*, t_contains_null; no new format)
- Why the kinds.
@.k != "x"is true for a JSON null member and false for a missing key, and!(@.k == "x")is true for both. The slot of a leaf is NULL in both cases; the kinds of column format minor 4 (docs/FORMATS.md, column minor 4) tell them apart, so the source reads the JSON nulls of the leaf (SNULL_JSON) and the missing keys (SNULL_MISSING: NULL and not a JSON null) next to the values. - Forms. For a literal of the JSON type of the member’s leaf (text, integer, boolean):
!=,!()over a comparison,&&and||of them, anullliteral (== null,!= null, under!()), variables of a constantvars($x), a strict path without!()(strict and lax differ for these only under!()), and@>with a JSON null in an object. A path of an element of an array of objects only. - What stays on the normal plan. A literal of another JSON type than the leaf’s (the other values are unknown, the overflow ones would not be
read); a number against a
float4/float8leaf (the leaf holds the rounded value,100.00000000000000001 > 100is true for jsonpath); strict!();jsonb_path_matchand@@;@>with a float,{}or a null among scalars. - Old units. A unit written by minor 3 has no kinds: the test raises the
REINDEXerror of the other type tests rather than answering.
Memory
A document never materializes: the jsonb iterator keeps one frame per nesting level, subtrees no declared path reaches are skipped, and output is bounded by SiftShredLimits: max_elems list elements per document (default 1 Mi) and max_bytes of text per document (default 16 MiB). The SiftShredDoc arrays are reused between documents and released when a document grew them past 64 Ki elements.
C interface
SiftShredSpec *sift_shred_spec_parse(const char *text); /* errors raise; allocated in the current context */
const char *sift_shred_spec_deparse(const SiftShredSpec *); /* canonical; spec->fingerprint */
SiftShred *sift_shred_create(const SiftShredSpec *, const SiftShredLimits *, bool cached); /* own memory context (cached: named "sift cache shred", for a relcache lifetime), NULL limits = defaults */
void sift_shred_free(SiftShred *);
/* portable event interface: any driver (the jsonb iterator, a JSON text parser) */
void sift_shred_begin(SiftShred *); /* next document; the dictionary survives */
SiftShredKey sift_shred_key(SiftShred *, const char *key, int len); /* SKIP | TAKE | STOP (the rest of the object is irrelevant) */
bool sift_shred_enter(SiftShred *, bool is_array); /* a container value; false: do not descend */
void sift_shred_leave(SiftShred *); /* after every enter that returned true */
bool sift_shred_wants_number(const SiftShred *); /* false: pass sift_shred_number(sh, NULL, 0) */
void sift_shred_null(SiftShred *); sift_shred_bool(sh, b); sift_shred_string(sh, s, len); sift_shred_number(sh, text, len);
bool sift_shred_full(const SiftShred *); /* the current list is cut short: stop iterating it */
void sift_shred_finish(SiftShred *);
/* PostgreSQL driver (col/shred_pg.h) */
void sift_shred_jsonb(SiftShred *, Jsonb *); /* begin, the document, finish */
void sift_shred_container(SiftShred *, JsonbContainer *); /* one jsonb as the next value (see below) */
/* the output: sh->doc.col[c], sh->doc.grp[g], sh->doc.text; helpers: */
void sift_shred_group_range(doc, g, p, &lo, &hi); void sift_shred_expand_parents(doc, g, parent);
Driver contract: object keys are unique; every value event follows a key that returned TAKE or STOP, or is an array element, or is the root; leave follows every enter that returned true. The shredder never reads ahead, so the caller can wrap a tuple’s events: sift_col shreds the object {attribute name: jsonb value, ...} of a tuple (spec paths start with the index attribute name) with begin, enter(object), key(attname), sift_shred_container(attribute's jsonb), leave, finish. A SQL NULL attribute is not sent (its columns stay ABSENT).
The jsonb driver walks with JsonbIteratorNext(skipNested): unwanted members are not entered, an object is left as soon as its last declared key was seen (STOP), a cut list stops the iteration. Numbers are rendered with numeric_normalize only when the target is a numeric column (wants_number).
SIMD kernels
Dispatch goes through sift_cpu_register (sift.simd, registered by the first sift_shred_create or sift_shred_init()); sift_shred_kernels_for(level) is for tests. Detection is at run time, so one library runs on any x86-64. On AArch64 col/shred_kern_neon.c is the NEON level (compiled in, nothing to detect); elsewhere the scalar table is used.
Kernels: state_counts (histogram of the four states of a column), state_bits (the NULL and overflow bitmaps of a column, for the engine’s chunk header), minmax_i64 / minmax_f64 (zone map over the VALUE slots). The offsets to per-element parent ordinals (sift_shred_expand_parents) are the column table’s expand_parents: one implementation for the shredder, the column scan and the aggregation executor. Number parsing is not dispatched: the digit runs are a few bytes, shorter than an indirect call.
Measured
bench/bench shred, one host (PostgreSQL 18, the testing build, -march=x86-64 baseline with run-time dispatch), best of 3 rounds with other jobs running: the figures are lower bounds, the ratios indicative.
| workload | shredder | plain SQL |
|---|---|---|
orders of about 1 KiB, 9 columns in 3 groups (items[], notes[], tags[], metrics.*), documents/s |
251 k | 2.0 M reading one key, 726 k jsonb_to_recordset of items only |
| 500 objects x 5 columns per document, elements/s | 3.2 M | 2.0 M jsonb_to_recordset |
| 82 keys, 3 declared paths, documents/s | 434 k | 616 k one #>> |
Kernels, 109 elements/s, scalar / AVX2 / AVX-512, 4096 elements (cache) and 4 Mi elements (memory):
| kernel | 4096 | 4 Mi |
|---|---|---|
state_counts |
0.63 / 22.5 / 27.5 | 0.57 / 25.3 / 16.2 |
state_bits |
0.95 / 34.4 / 57.7 | 0.81 / 35.1 / 36.4 |
minmax_i64 |
0.93 / 4.3 / 10.2 | 0.50 / 2.0 / 1.1 |
minmax_f64 |
0.66 / 6.5 / 8.3 | 0.55 / 2.0 / 1.1 |
expand_parents (runs of 4; now SiftColKernels.expand_parents) |
2.46 / 2.24 / 3.04 | 2.37 / 2.27 / 1.32 |
expand_parents gains nothing from vectors on runs shorter than a register; on a column that does not fit the cache, AVX2 is as fast or faster than AVX-512 (memory bound on this host).
On AArch64 (a test host, clang -O2, one thread, best of 300 batches and of three runs), 109 elements/s, scalar / NEON:
| kernel | 4096 | 4 Mi |
|---|---|---|
state_counts |
0.86 / 35 | 0.82 / 35 |
state_bits |
2.3 / 48 | 2.1 / 46 |
minmax_i64 |
1.5 / 2.3 | 1.4 / 2.2 |
minmax_f64 |
0.82 / 3.6 | 0.78 / 3.5 |
The NEON kernels count with byte compares (a byte lane takes 255 vectors before it is added up), build bitmaps with the pairwise-add movemask of core/neon.h, and select VALUE lanes of min / max with BSL (FMIN/FMAX for doubles, never NaN; integers compare and select, NEON has no 64-bit min).
Arrays of objects, dynamic keys and nested objects (3.1)
Historical 3.1 baseline: 100,000 synthetic documents (387,593 elements: items[]{kind,sub,flag,score}, scores.* with 2 to 7 of 20 dynamic keys, attrs{channel, nested.a.b, tags[], mixed}), PostgreSQL 18.6, one backend (max_parallel_workers_per_gather = 0), best of 5, warm, a busy host: the ratios are the finding. bench/sql/jsonb/ has the generator, the index (paths = 'attrs->channel text, attrs.nested.a.b int4, attrs.tags[] text, scores.* float8, items[]{kind text, sub text, flag text, score float8}') and the queries count-filter to typeof-filter; each runs with sift.enable_agg on and off (equal rows). index_typeof.sql adds attrs.mixed float8 for mixed-numeric, and bench/sql/jsonb/rollup/ measures the element rollups (COL-PREAGG.md).
| size | build | |
|---|---|---|
| heap table, jsonb inline | 56 MB | |
sift_col on id, grp, day, attrs, scores, items with the paths above |
2.3 MB | 609 ms |
GIN jsonb_path_ops on items, attrs, scores |
6.2 MB | 651 ms |
the same documents as a sift_table, no paths |
11 MB |
| shape | PostgreSQL ms | sift ms | |
|---|---|---|---|
| count-filter | count(*) FILTER per flag over jsonb_to_recordset(items) |
175 | 3.7 |
| group-elem | GROUP BY e.kind, count, avg(e.score) |
192 | 4.9 |
| group-two-filter | two keys, element and document predicates | 75 | 3.7 |
| exists-elem | EXISTS element with two conditions |
88 | 2.4 |
| jsonpath-exists | jsonb_path_exists(items, '$[*] ? (...)') |
45 | 2.2 |
| contains | items @> '[{"kind": .., "flag": ..}]' (10% of the documents; GIN bitmap scan: 27.5) |
23 | 2.4 |
| per-parent-avg | per-document avg per kind, then an outer aggregate (the outer one runs in PostgreSQL) |
592 | 284 |
| distinct-parent-filter | count(DISTINCT d.id) FILTER (WHERE e.score >= 8) per kind |
514 | 5.6 |
| object-each | jsonb_each_text(scores) with a regex on the value (float8 paths: not pushed; with astext paths, index_astext.sql: 7.0) |
229 | 254 |
| nested-scalar | attrs #>> '{nested,a,b}' |
47 | 2.1 |
| channel-month | attrs->>'channel' and date_trunc('month', day) |
53 | 2.3 |
| tags-unnest | jsonb_array_elements_text(attrs->'tags') grouped by the tag |
85 | 2.6 |
| tags-contains | attrs->'tags' ? 't3' |
33 | 1.8 |
| score-cast-text | (e->>'score')::float8 of jsonb_array_elements(items) with a regex on e->>'score' (float8 paths: not pushed; astext paths: 6.6) |
257 | 263 |
| array-length | jsonb_array_length(items) as a key |
42 | 3.0 |
| mixed-numeric | jsonb_typeof(attrs->'mixed') = 'number' (column format minor 4) |
100 | 2.1 |
| item-agg-having | two keys, HAVING count(*) > 100 |
233 | 5.1 |
| join-lookup | element keys joined to a lookup table | 317 | 9.1 |
The pushed shapes are 10 to 100 times faster than the normal plan. Against GIN on containment: 9 times faster at 10% of the documents (contains), level when the match is selective (three conditions: GIN 8.0, sift 8.7 ms), where GIN stays useful. ClickHouse Array(Tuple(kind, sub, flag, score)) from the same documents (clickhouse-local, 2 threads) answers count-filter to exists-elem in 0.6 to 0.8 ms, 4 to 6 times faster than SiftAgg: the gap is the per-tuple selection (80% of 88 instructions per element in count-filter), not the decoding. Strings among the numbers (strs = 0.05 of gen.sql) raise the overflow error of the pushdown for the aggregates that read a numeric path, except for the type filters of mixed-numeric (33 times faster than the normal plan at 1,000,000 documents: 396 against 11.9 ms).
Integration notes
expand_parentsis the column table’s.state_counts,state_bitsandminmax_*overlap with the engine’s kernels only in what they compute: they work on the shredder’sstatebytes before the engine has bitmaps.- Why the fused kernels stay: the composition from the generic kernels (measured by
tools/kernels,make bench-shred, assift_colk->range[SIFT_COLW_8]on the bytes (state_bits:range(state, VALUE + 1, 254)for the NULL bitmap,range(state, OVERFLOW, 0)for the overflow one;state_counts: a range and abits_countper state) andSiftAggKernels.minmax_*under the bitmap of theVALUEslots) is compared with the fused kernel.- The composition loses by more than 3 % at the scalar level for every kernel (
state_countson a column ofVALUEs 6x,minmax_f642.1x,minmax_i641.5x,state_bits1.09x), at AVX2 forstate_bits,state_countsandminmax_f64(1.5x, 1.4x, 1.14x) and at AVX-512 forminmax_f64(1.26x); it also needs the value arrays to haveSIFT_AGG_PADreadable bytes behind them (the shredder’s arrays do not promise it). - It wins at AVX-512 for
state_bitsandstate_counts(0.7x at 4096 elements) and forminmax_i64at AVX2 and AVX-512 at 4 Mi elements; a level that is better composed does not make the scalar level’s loss go away, and a table that mixes the two would keep both implementations. - When the adapter has bitmaps anyway (
state_bits), the engine’sminmax_*apply as they are. The AVX2minmax_i64here is the weak one (a single dependency chain): the aggregation kernel’s four accumulator pairs would make it 1.8x faster.
- The composition loses by more than 3 % at the scalar level for every kernel (
- Each module that owns a dispatch table registers one rebind with
sift_cpu_register(SIFT_MAX_REBINDSis 8).
Tests
sift._test_shred(jsonb, spec) (testing build) returns one row per slot and per list. test/sql/shred_*.sql cover the grammar and the errors (shred_spec), the edge cases (shred_values: absent versus null, empty arrays, mixed types, nesting, huge numbers, unicode, duplicate keys, limits), a differential test on random documents (shred_diff: a plpgsql oracle over jsonb_each, jsonb_array_elements and the jsonb casts for every slot of clean and dirty documents; jsonb_to_recordset, jsonb_path_query and SQL aggregates for the clean ones; the kernel summaries against SQL aggregates) and the SIMD levels against scalar (shred_kernels). bench/bench shred measures documents per second (bench/steps/shred.sh, bench/sql/shred.sql).