From 030ec5c343ab4bdd34a870a71f810ea381d49bce Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 6 Aug 2026 17:38:33 -0400 Subject: [PATCH] feat(tables): support first-class fixed-size-list GPU columns --- docs/api-reference/tables/gpu-data.mdx | 21 +- docs/api-reference/tables/gpu-schema.mdx | 20 +- .../tables/gpu-table-lifecycle.mdx | 3 + docs/api-reference/tables/gpu-table.mdx | 4 +- .../tables/gpu-vector-format.mdx | 67 ++- docs/api-reference/tables/gpu-vector.mdx | 28 +- .../src/engine/gpu-table-computation.ts | 9 +- .../src/engine/gpu-table-shader-bindings.ts | 10 +- modules/tables/src/index.ts | 2 + modules/tables/src/table/gpu-constant.ts | 3 + modules/tables/src/table/gpu-data.ts | 48 +- modules/tables/src/table/gpu-record-batch.ts | 4 + modules/tables/src/table/gpu-schema.ts | 5 +- modules/tables/src/table/gpu-table.ts | 26 + modules/tables/src/table/gpu-vector-format.ts | 77 ++- modules/tables/src/table/gpu-vector.ts | 44 +- .../test/table/gpu-constant.node.spec.ts | 9 + .../test/table/gpu-data-types.node.spec.ts | 23 +- .../table/gpu-table-computation.node.spec.ts | 95 +++ .../gpu-table-shader-bindings.node.spec.ts | 49 ++ .../test/table/gpu-vector-format.node.spec.ts | 567 ++++++++++++++++++ 21 files changed, 1067 insertions(+), 47 deletions(-) create mode 100644 modules/tables/test/table/gpu-table-computation.node.spec.ts diff --git a/docs/api-reference/tables/gpu-data.mdx b/docs/api-reference/tables/gpu-data.mdx index 9509e52131..c2168cdc2a 100644 --- a/docs/api-reference/tables/gpu-data.mdx +++ b/docs/api-reference/tables/gpu-data.mdx @@ -112,7 +112,7 @@ order, offsets, formats, row stride, and optional `stepMode`. | `format` | `GPUVectorFormat \| GPUDataStructFields` | `undefined` | A scalar/list format string or an inline record of named fixed-width field formats. | | `layout` | `wgsl-storage \| packed` | `wgsl-storage` | Physical packing rules for an inline struct format. Invalid with a scalar/list format. | | `length` | `number` | Required | Number of logical rows in this chunk. | -| `valueLength` | `number` | `length` | Number of fixed rows or flattened vertex-list element values. | +| `valueLength` | `number` | Derived from `format` | Number of fixed rows, or flattened variable-list/fixed-size-list element values. | | `stride` | `number` | Derived from `format` | Number of scalar values represented by one fixed row or flattened element. | | `byteOffset` | `number` | `0` | Byte offset of the first logical row in this chunk's buffer. Most adapters use `0` because chunks own their uploaded buffers. | | `byteStride` | `number` | Derived from `format` | Bytes between adjacent fixed rows or flattened elements. | @@ -130,7 +130,7 @@ order, offsets, formats, row stride, and optional `stepMode`. | `type` | `unknown` | Deprecated adapter-owned logical metadata. | | `dataType` | `unknown` | Deprecated adapter-owned logical metadata. | | `length` | `number` | Number of logical rows in this chunk. | -| `valueLength` | `number` | Number of fixed rows or flattened vertex-list element values. | +| `valueLength` | `number` | Number of fixed rows, or flattened variable-list/fixed-size-list element values. | | `stride` | `number` | Number of scalar values represented by one fixed row or flattened element. | | `byteOffset` | `number` | Byte offset of the first logical row. | | `byteStride` | `number` | Bytes between adjacent fixed rows or flattened elements. | @@ -138,6 +138,23 @@ order, offsets, formats, row stride, and optional `stepMode`. | `readbackMetadata` | `unknown` | Optional producer-owned metadata. | | `ownsBuffer` | `boolean` | Whether this data range currently owns its backing buffer. | +Fixed-size storage lists preserve table rows independently from flattened scalar +coordinates: + +```ts +const embeddings = new GPUData({ + buffer, + format: 'fixed-size-list', + length: 1000, + ownsBuffer: true +}); + +embeddings.length; // 1000 logical table rows +embeddings.valueLength; // 768000 Float32 elements +embeddings.rowByteLength; // 3072 bytes +embeddings.byteStride; // 3072 bytes, unless rows are explicitly padded +``` + ## Methods ### `getChild(name): GPUDataView | null` diff --git a/docs/api-reference/tables/gpu-schema.mdx b/docs/api-reference/tables/gpu-schema.mdx index c93fe88b8d..b9bb3eeaf9 100644 --- a/docs/api-reference/tables/gpu-schema.mdx +++ b/docs/api-reference/tables/gpu-schema.mdx @@ -23,13 +23,13 @@ For the required and optional `GPUVector` inputs accepted by a model, see ```ts import type {VertexFormat} from '@luma.gl/core'; -import type {GPUVectorFormat, VertexList} from '@luma.gl/tables'; +import type {FixedSizeList, GPUVectorFormat, VertexList} from '@luma.gl/tables'; export type GPUTypeMap = Record; export type GPUField< Name extends string = string, - Format extends VertexFormat | VertexList = GPUVectorFormat + Format extends GPUVectorFormat = GPUVectorFormat > = { name: Name; format?: Format; @@ -68,6 +68,20 @@ type PathTable = { }; ``` +Fixed-size storage values retain their row cardinality in the column format: + +```ts +type EmbeddingTable = { + embedding: FixedSizeList<'float32', 768>; + sourceId: 'uint32'; + embeddingValidity: 'uint32'; +}; +``` + +Every column has the same logical table row count. The embedding allocation +contains 768 Float32 elements per row; its source IDs and optional GPU-validity +mask remain independently owned, ordinary row-aligned Uint32 columns. + ## Semantics `GPUSchema` describes selected GPU-facing columns, not necessarily every source @@ -79,6 +93,8 @@ path. - fixed vectors use core `VertexFormat` strings such as `float32x3`; - variable-length vertex lists use `vertex-list`; +- variable-length non-vertex values use `value-list`; +- fixed-size storage values use `fixed-size-list`; - shader values remain in `ShaderLayout`, such as `vec3` or `vec4`. Compatibility between `GPUField.format` and shader values is checked separately diff --git a/docs/api-reference/tables/gpu-table-lifecycle.mdx b/docs/api-reference/tables/gpu-table-lifecycle.mdx index 07a4f172e3..ed839614e1 100644 --- a/docs/api-reference/tables/gpu-table-lifecycle.mdx +++ b/docs/api-reference/tables/gpu-table-lifecycle.mdx @@ -55,6 +55,9 @@ Packing mutates the table in place. It rebuilds `batches[]` and table-level removed batches. Borrowed external buffers are not destroyed. Indexed tables are rejected because packed index buffers would need their batch-local vertex indices rebased. +Nullable chunks and chunks carrying producer-owned readback metadata are also +rejected rather than silently losing source validity or adapter reconstruction +information. Non-null fixed-size-list columns can still be packed explicitly. ### Ownership Rules diff --git a/docs/api-reference/tables/gpu-table.mdx b/docs/api-reference/tables/gpu-table.mdx index aba8868332..54475584b1 100644 --- a/docs/api-reference/tables/gpu-table.mdx +++ b/docs/api-reference/tables/gpu-table.mdx @@ -95,7 +95,9 @@ This means `table.schema.fields` may contain names absent from Explicitly merges adjacent physical batches. Constants survive unchanged and do not participate in copies. Indexed and variable-length restrictions still apply to the -varying data being packed. +varying data being packed. Columns carrying null bitmaps or producer-owned +readback metadata are rejected until a metadata-preserving packing contract is +available; ordinary non-null fixed-size-list storage columns remain packable. ### `addBatch(batch): this` diff --git a/docs/api-reference/tables/gpu-vector-format.mdx b/docs/api-reference/tables/gpu-vector-format.mdx index 18fc61ed67..4869480d6c 100644 --- a/docs/api-reference/tables/gpu-vector-format.mdx +++ b/docs/api-reference/tables/gpu-vector-format.mdx @@ -25,7 +25,15 @@ import type {VertexFormat} from '@luma.gl/core'; export type VertexList = `vertex-list<${Format}>`; -export type GPUVectorFormat = VertexFormat | VertexList; +export type ValueList = + `value-list<${Format}>`; + +export type FixedSizeList< + Format extends VertexFormat = VertexFormat, + Size extends number = number +> = `fixed-size-list<${Format},${Size}>`; + +export type GPUVectorFormat = VertexFormat | VertexList | ValueList | FixedSizeList; export type GPUDataFormat = GPUVectorFormat | GPUDataStructFormat; ``` @@ -54,8 +62,25 @@ per-vertex element values. The format inside the angle brackets describes one flattened element. Offset buffers, row ranges, closed-path flags, text glyph maps, and similar topology metadata are adapter-owned. -Generic `list` is intentionally reserved for a possible future -non-vertex offset-list type. +Variable-length non-vertex values use `value-list`. For example, +`value-list` stores flattened UTF-8 bytes with producer-owned row offsets. + +Fixed-size storage values keep their logical row width in the format itself: + +```ts +'fixed-size-list' +'fixed-size-list' +'fixed-size-list' +``` + +`fixed-size-list` describes 768 stored Float32 values in each table +row. It does **not** describe a vertex attribute, a WGSL `vec768`, or a +new Arrow-owned GPU type. `GPUData.length` and `GPUVector.length` remain logical +row counts; `valueLength` counts the flattened elements. The default row +`byteStride` and `rowByteLength` are `768 * 4`, while an explicit larger +`byteStride` preserves physical row padding. + +Generic `list` remains intentionally reserved. `GPUDataStructFormat` is an object rather than another format string because it contains named field formats, offsets, and row-stride metadata. It remains a @@ -67,7 +92,7 @@ format of one logical vector. ### `getGPUVectorFormatInfo(format): GPUVectorFormatInfo` -Decodes a fixed or `vertex-list<...>` format string. +Decodes fixed scalar/vector, variable-list, or fixed-size-list storage formats. ```ts const info = getGPUVectorFormatInfo('vertex-list'); @@ -77,17 +102,35 @@ info.vertexList; // true info.components; // 3 info.byteLength; // 12 info.primitiveType; // 'f32' + +const embedding = getGPUVectorFormatInfo('fixed-size-list'); + +embedding.elementFormat; // 'float32' +embedding.fixedSizeList; // true +embedding.listSize; // 768 +embedding.elementByteLength; // 4 +embedding.byteLength; // 3072 bytes per logical row ``` ### `getGPUVectorElementFormat(format): VertexFormat` -Returns the fixed element format. For fixed vectors this is the input format; for -vertex lists this is the format inside `vertex-list<...>`. +Returns the fixed element format. For scalar/vector formats this is the input; +for variable and fixed-size lists it is the element format inside the brackets. ### `isVertexListGPUVectorFormat(format): boolean` Returns true for `vertex-list<...>` formats. +### `isValueListGPUVectorFormat(format): boolean` + +Returns true for variable-length non-vertex `value-list<...>` formats. + +### `isFixedSizeListGPUVectorFormat(format): boolean` + +Returns true for canonical `fixed-size-list` formats. Sizes are +positive integers; whitespace, leading zeros, missing sizes, and unsupported +element formats are rejected. + ### `isGPUVectorFormatCompatibleWithShaderType(format, shaderType): boolean` Checks whether the memory format can feed one shader attribute type. @@ -101,6 +144,7 @@ Examples: | `uint32x2` | `vec2` | yes | Unsigned integer primitive type matches. | | `sint32x2` | `vec2` | no | Signedness mismatch. | | `float32x3` | `vec4` | no | Component count mismatch. | +| `fixed-size-list` | `vec4` | no | Fixed-size lists are storage columns, not vertex attributes. | ## Buffer Layouts @@ -136,9 +180,10 @@ const positions = new GPUVector({ }); ``` -`vertex-list<...>` vectors do not synthesize generic vertex-buffer layouts. -Path, text, polygon, and geometry adapters must either expand them into -renderable fixed vectors or bind them through an explicit storage/offset path. +`vertex-list<...>`, `value-list<...>`, and `fixed-size-list<...>` vectors do not +synthesize generic vertex-buffer layouts. Path, text, polygon, geometry, and +embedding adapters must either expand compatible values into renderable fixed +vectors or bind them explicitly through storage. ## Arrow Mapping @@ -151,5 +196,9 @@ renderable fixed vectors or bind them through an explicit storage/offset path. | `List>` path coordinates | `vertex-list` | | `List>` vertex colors | `vertex-list` | +Short fixed-size lists preserve their existing vertex-attribute mappings. +Fixed-size-list GPU formats can describe wider caller-created table storage +without inventing unsupported vertex formats such as `float32x768`. + Arrow data types remain adapter/readback metadata. Table core uses `GPUVectorFormat`. diff --git a/docs/api-reference/tables/gpu-vector.mdx b/docs/api-reference/tables/gpu-vector.mdx index 6cda171f2b..c13bea10b5 100644 --- a/docs/api-reference/tables/gpu-vector.mdx +++ b/docs/api-reference/tables/gpu-vector.mdx @@ -55,7 +55,7 @@ When the input starts as Apache Arrow, prefer | `type` | `unknown` | Deprecated adapter-owned logical metadata. | | `dataType` | `unknown` | Deprecated adapter-owned logical metadata. | | `length` | `number` | Aggregate logical row count. | -| `valueLength` | `number` | Aggregate fixed-row or flattened vertex-list element count. | +| `valueLength` | `number` | Aggregate scalar-row count, flattened variable-list count, or fixed-size-list element count. | | `stride` | `number` | Number of scalar values represented by one fixed row or flattened element. | | `byteOffset` | `number` | Compatibility metadata for the first row when this vector has one chunk. | | `byteStride` | `number` | Bytes between adjacent fixed rows or flattened elements. | @@ -101,3 +101,29 @@ For `vertex-list<...>` vectors, `length` remains the source row count and `valueLength` is the flattened element count. `byteStride` and `rowByteLength` describe one flattened element, not one source row. Offsets and other variable-length metadata are adapter-owned. + +For `fixed-size-list` vectors, `length` remains the number of table +rows while `valueLength` is `length * 768`. Each `GPUData` chunk stores one +complete fixed-size-list row per `byteStride`; `rowByteLength` describes its +meaningful payload and may be smaller than a padded physical stride. These +columns are storage-only unless an application explicitly expands them into +shader-compatible attributes. When binding a packed column through generic +WebGPU table shader/computation helpers, its `byteOffset` must satisfy the +device's `minStorageBufferOffsetAlignment`; consumers that align bindings +internally may support additional suballocation offsets. + +```ts +import {GPUVector, type FixedSizeList} from '@luma.gl/tables'; + +const embeddings = new GPUVector>({ + type: 'buffer', + name: 'embedding', + buffer: embeddingBuffer, + format: 'fixed-size-list', + length: rowCount +}); + +embeddings.length; // Logical source rows. +embeddings.valueLength; // Flattened Float32 embedding coordinates. +embeddings.data; // Original caller-owned GPUData batch chunks. +``` diff --git a/modules/tables/src/engine/gpu-table-computation.ts b/modules/tables/src/engine/gpu-table-computation.ts index b008c93d87..450e3bf0c6 100644 --- a/modules/tables/src/engine/gpu-table-computation.ts +++ b/modules/tables/src/engine/gpu-table-computation.ts @@ -7,6 +7,7 @@ import {Computation, type ComputationProps} from '@luma.gl/engine'; import {DynamicBuffer} from '@luma.gl/engine'; import type {GPUData} from '../table/gpu-data'; import type {GPUVector} from '../table/gpu-vector'; +import {isFixedSizeListGPUVectorFormat} from '../table/gpu-vector-format'; /** Metadata supplied to one GPU table computation batch dispatch. */ export type GPUTableComputationBatch = { @@ -160,10 +161,16 @@ function getBatchVectorBindings( } function getGPUDataBinding(data: GPUData): Binding { + const fixedSizeListByteLength = + data.format && isFixedSizeListGPUVectorFormat(data.format) + ? data.length === 0 + ? 0 + : (data.length - 1) * data.byteStride + data.rowByteLength + : undefined; return { buffer: getGPUDataBuffer(data), offset: data.byteOffset, - size: data.length * data.byteStride + size: data.valueByteLength ?? fixedSizeListByteLength ?? data.length * data.byteStride }; } diff --git a/modules/tables/src/engine/gpu-table-shader-bindings.ts b/modules/tables/src/engine/gpu-table-shader-bindings.ts index d02f865e51..9a4e70f178 100644 --- a/modules/tables/src/engine/gpu-table-shader-bindings.ts +++ b/modules/tables/src/engine/gpu-table-shader-bindings.ts @@ -23,7 +23,7 @@ import { import {GPUConstant} from '../table/gpu-constant'; import type {GPUData} from '../table/gpu-data'; import type {GPUTable} from '../table/gpu-table'; -import {getGPUVectorFormatInfo} from '../table/gpu-vector-format'; +import {getGPUVectorFormatInfo, isFixedSizeListGPUVectorFormat} from '../table/gpu-vector-format'; type GPUBuffer = Buffer | DynamicBuffer; @@ -530,10 +530,16 @@ function getShaderAttributeBufferLayout( } function getGPUDataBinding(data: GPUData): Binding { + const fixedSizeListByteLength = + data.format && isFixedSizeListGPUVectorFormat(data.format) + ? data.length === 0 + ? 0 + : (data.length - 1) * data.byteStride + data.rowByteLength + : undefined; return { buffer: data.buffer instanceof DynamicBuffer ? data.buffer.buffer : data.buffer, offset: data.byteOffset, - size: data.valueByteLength ?? data.valueLength * data.byteStride + size: data.valueByteLength ?? fixedSizeListByteLength ?? data.valueLength * data.byteStride }; } diff --git a/modules/tables/src/index.ts b/modules/tables/src/index.ts index 3954beaea1..63aecad748 100644 --- a/modules/tables/src/index.ts +++ b/modules/tables/src/index.ts @@ -50,9 +50,11 @@ export { export { getGPUVectorElementFormat, getGPUVectorFormatInfo, + isFixedSizeListGPUVectorFormat, isGPUVectorFormatCompatibleWithShaderType, isValueListGPUVectorFormat, isVertexListGPUVectorFormat, + type FixedSizeList, type GPUVectorFormat, type GPUVectorFormatInfo, type ValueList, diff --git a/modules/tables/src/table/gpu-constant.ts b/modules/tables/src/table/gpu-constant.ts index 34b1006a62..db89149b42 100644 --- a/modules/tables/src/table/gpu-constant.ts +++ b/modules/tables/src/table/gpu-constant.ts @@ -30,6 +30,9 @@ export class GPUConstant { constructor({format, value}: GPUConstantProps) { const formatInfo = getGPUVectorFormatInfo(format); + if (formatInfo.fixedSizeList) { + throw new Error('GPUConstant cannot represent fixed-size-list storage columns'); + } const expectedConstructor = getGPUConstantTypedArrayConstructor(format); if (value.constructor !== expectedConstructor) { throw new Error( diff --git a/modules/tables/src/table/gpu-data.ts b/modules/tables/src/table/gpu-data.ts index 4a9e6d2cab..e6e2a10d7e 100644 --- a/modules/tables/src/table/gpu-data.ts +++ b/modules/tables/src/table/gpu-data.ts @@ -42,7 +42,7 @@ type GPUDataFromBufferBaseProps = { buffer: Buffer | DynamicBuffer; /** Number of logical rows in the data range. */ length: number; - /** Number of fixed rows or flattened vertex-list values in the data range. */ + /** Number of fixed rows, fixed-list elements, or flattened variable-length values. */ valueLength?: number; /** Number of scalar values represented by one fixed row or flattened element. */ stride?: number; @@ -153,7 +153,7 @@ class GPUDataImpl extends GPUDataBufferOwner { readonly format?: GPUDataFormat; /** Number of logical rows in this chunk. */ readonly length: number; - /** Number of fixed rows or flattened vertex-list values in this chunk. */ + /** Number of fixed rows, fixed-list elements, or flattened variable-length values. */ readonly valueLength: number; /** Number of scalar values represented by one fixed row or flattened element. */ readonly stride: number; @@ -210,10 +210,10 @@ class GPUDataImpl extends GPUDataBufferOwner { this.dataType = dataType; this.format = canonicalFormat; this.length = length; - this.valueLength = valueLength ?? length; + this.valueLength = valueLength ?? length * (formatInfo?.listSize ?? 1); this.stride = stride ?? - formatInfo?.components ?? + (formatInfo ? formatInfo.components * (formatInfo.listSize ?? 1) : undefined) ?? structFormat?.components ?? byteStride ?? rowByteLength ?? @@ -227,6 +227,44 @@ class GPUDataImpl extends GPUDataBufferOwner { this.stride; this.byteStride = byteStride ?? structFormat?.byteStride ?? this.rowByteLength; + if (formatInfo?.fixedSizeList) { + const expectedValueLength = length * formatInfo.listSize!; + if ( + !Number.isSafeInteger(length) || + length < 0 || + !Number.isSafeInteger(expectedValueLength) || + !Number.isSafeInteger(this.byteOffset) || + this.byteOffset < 0 || + !Number.isSafeInteger(this.byteStride) || + !Number.isSafeInteger(this.rowByteLength) || + !Number.isSafeInteger(this.stride) + ) { + throw new Error('GPUData fixed-size-list row layout must use safe non-negative integers'); + } + if (this.valueLength !== expectedValueLength) { + throw new Error( + 'GPUData fixed-size-list valueLength must equal its flattened row elements' + ); + } + if (this.stride < formatInfo.components * formatInfo.listSize!) { + throw new Error('GPUData fixed-size-list stride cannot truncate its row components'); + } + if (this.rowByteLength < formatInfo.byteLength) { + throw new Error('GPUData fixed-size-list rowByteLength cannot truncate its row payload'); + } + if (this.byteStride < this.rowByteLength) { + throw new Error('GPUData fixed-size-list byteStride cannot overlap its row payload'); + } + const dataByteLength = length === 0 ? 0 : (length - 1) * this.byteStride + this.rowByteLength; + const endByteOffset = this.byteOffset + dataByteLength; + if (!Number.isSafeInteger(dataByteLength) || !Number.isSafeInteger(endByteOffset)) { + throw new Error('GPUData fixed-size-list byte range must use safe integers'); + } + if (endByteOffset > buffer.byteLength) { + throw new Error('GPUData fixed-size-list exceeds its backing buffer byte length'); + } + } + // Explicit row overrides may add padding but cannot truncate the computed struct layout. if (structFormat) { if (this.rowByteLength < structFormat.rowByteLength) { @@ -298,7 +336,7 @@ interface GPUDataBase { readonly format?: Format; /** Number of logical rows in this chunk. */ readonly length: number; - /** Number of fixed rows or flattened vertex-list values in this chunk. */ + /** Number of fixed rows, fixed-list elements, or flattened variable-length values. */ readonly valueLength: number; /** Number of scalar values represented by one fixed row or flattened element. */ readonly stride: number; diff --git a/modules/tables/src/table/gpu-record-batch.ts b/modules/tables/src/table/gpu-record-batch.ts index f5c2e29619..d995d2f759 100644 --- a/modules/tables/src/table/gpu-record-batch.ts +++ b/modules/tables/src/table/gpu-record-batch.ts @@ -8,6 +8,7 @@ import type {GPUField, GPUSchema, GPUTypeMap} from './gpu-schema'; import {isGPUTableIndexColumnName} from './gpu-schema'; import { getGPUVectorElementFormat, + isFixedSizeListGPUVectorFormat, isValueListGPUVectorFormat, isVertexListGPUVectorFormat } from './gpu-vector-format'; @@ -144,6 +145,9 @@ function synthesizeGPUDataBufferLayout(name: string, data: GPUData): BufferLayou `GPURecordBatch cannot synthesize a buffer layout for GPUData "${name}" without a format` ); } + if (isFixedSizeListGPUVectorFormat(data.format)) { + return []; + } if (isVertexListGPUVectorFormat(data.format)) { throw new Error( `GPURecordBatch cannot synthesize a generic buffer layout for vertex-list GPUData "${name}"` diff --git a/modules/tables/src/table/gpu-schema.ts b/modules/tables/src/table/gpu-schema.ts index 459d86292c..935bbd7c02 100644 --- a/modules/tables/src/table/gpu-schema.ts +++ b/modules/tables/src/table/gpu-schema.ts @@ -18,8 +18,9 @@ export function isGPUTableIndexColumnName( * Named GPU table columns mapped to their canonical memory formats. * * The value type is a memory-layout string such as `float32x3`, - * `unorm8x4`, `vertex-list`, or `value-list`. Shader value declarations live in - * `ShaderLayout`; compatibility is checked at adapter boundaries. + * `unorm8x4`, `vertex-list`, `value-list`, or + * `fixed-size-list`. Shader value declarations live in `ShaderLayout`; + * compatibility is checked at adapter boundaries. */ export type GPUTypeMap = Record; diff --git a/modules/tables/src/table/gpu-table.ts b/modules/tables/src/table/gpu-table.ts index b66a5efc0a..c1752c95d3 100644 --- a/modules/tables/src/table/gpu-table.ts +++ b/modules/tables/src/table/gpu-table.ts @@ -13,6 +13,7 @@ import {GPU_TABLE_INDEX_COLUMN_NAME, isGPUTableIndexColumnName} from './gpu-sche import { getGPUVectorElementFormat, type GPUVectorFormat, + isFixedSizeListGPUVectorFormat, isValueListGPUVectorFormat, isVertexListGPUVectorFormat } from './gpu-vector-format'; @@ -218,6 +219,28 @@ export class GPUTable { } const batchGroups = createGPUPackGroups(this.batches, options.minBatchSize); + for (const batchGroup of batchGroups) { + if (batchGroup.length <= 1) { + continue; + } + for (const batch of batchGroup) { + for (const [columnName, data] of Object.entries(batch.gpuData)) { + if ( + data.format && + (isValueListGPUVectorFormat(data.format) || isVertexListGPUVectorFormat(data.format)) + ) { + throw new Error( + `GPUTable.packBatches() does not support variable-length GPUData "${columnName}"` + ); + } + if (data.nullBitmap?.length || data.readbackMetadata !== undefined) { + throw new Error( + `GPUTable.packBatches() cannot preserve null or readback metadata for GPUData "${columnName}"` + ); + } + } + } + } const nextBatches: GPURecordBatch[] = []; const supersededBatches: GPURecordBatch[] = []; @@ -846,6 +869,9 @@ function synthesizeGPUVectorBufferLayout( 'GPUTable cannot synthesize a buffer layout for vector "' + vector.name + '" without a format' ); } + if (isFixedSizeListGPUVectorFormat(vector.format)) { + return []; + } if (isVertexListGPUVectorFormat(vector.format)) { if (allowVariableLengthWithoutLayout) { return []; diff --git a/modules/tables/src/table/gpu-vector-format.ts b/modules/tables/src/table/gpu-vector-format.ts index dcdc0b4eb0..a0538160d7 100644 --- a/modules/tables/src/table/gpu-vector-format.ts +++ b/modules/tables/src/table/gpu-vector-format.ts @@ -29,14 +29,26 @@ export type VertexList = `vertex-lis */ export type ValueList = `value-list<${Format}>`; +/** + * Fixed-length rows of element values consumed through GPU storage bindings. + * + * `fixed-size-list` stores exactly 768 `float32` elements in every + * logical row. The list describes physical memory, not a shader vertex format. + */ +export type FixedSizeList< + Format extends VertexFormat = VertexFormat, + Size extends number = number +> = `fixed-size-list<${Format},${Size}>`; + /** * Memory-layout string used by GPUVector. * * Fixed formats reuse core `VertexFormat` strings. Variable-length * vertex-aligned formats use `vertex-list<${VertexFormat}>`; other - * variable-length values use `value-list<${VertexFormat}>`. + * variable-length values use `value-list<${VertexFormat}>`. Storage-oriented + * fixed-length rows use `fixed-size-list<${VertexFormat},${number}>`. */ -export type GPUVectorFormat = VertexFormat | VertexList | ValueList; +export type GPUVectorFormat = VertexFormat | VertexList | ValueList | FixedSizeList; /** Decoded memory-layout information for a GPUVector format string. */ export type GPUVectorFormatInfo = { @@ -48,6 +60,10 @@ export type GPUVectorFormatInfo = { vertexList: boolean; /** Whether this vector stores row-offset non-vertex value lists. */ valueList: boolean; + /** Whether every logical row contains a fixed number of storage elements. */ + fixedSizeList: boolean; + /** Number of elements in each fixed-size-list row, when applicable. */ + listSize?: number; /** Component memory data type. */ type: NormalizedDataType; /** Component memory data type without normalization. */ @@ -56,7 +72,9 @@ export type GPUVectorFormatInfo = { primitiveType: PrimitiveDataType; /** Number of scalar components per fixed row or list element. */ components: 1 | 2 | 3 | 4; - /** Bytes occupied by one fixed row or list element. */ + /** Bytes occupied by one scalar or vector element. */ + elementByteLength: number; + /** Bytes occupied by one fixed row, or by one variable-length list element. */ byteLength: number; /** Whether shader-visible values are integer values. */ integer: boolean; @@ -70,6 +88,7 @@ export type GPUVectorFormatInfo = { const VERTEX_LIST_FORMAT_REGEXP = /^vertex-list<([^<>]+)>$/; const VALUE_LIST_FORMAT_REGEXP = /^value-list<([^<>]+)>$/; +const FIXED_SIZE_LIST_FORMAT_REGEXP = /^fixed-size-list<([^<>,]+),([1-9][0-9]*)>$/; /** Returns true when a GPUVector format describes row-offset vertex lists. */ export function isVertexListGPUVectorFormat(format: string): format is VertexList { @@ -81,11 +100,20 @@ export function isValueListGPUVectorFormat(format: string): format is ValueList return VALUE_LIST_FORMAT_REGEXP.test(format); } +/** Returns true when a GPUVector format describes canonical fixed-length rows. */ +export function isFixedSizeListGPUVectorFormat(format: string): format is FixedSizeList { + return Boolean(getFixedSizeListFormatParts(format)); +} + /** Returns the fixed element memory format for fixed and variable-length vectors. */ export function getGPUVectorElementFormat(format: GPUVectorFormat): VertexFormat { const vertexListMatch = VERTEX_LIST_FORMAT_REGEXP.exec(format); const valueListMatch = VALUE_LIST_FORMAT_REGEXP.exec(format); - const elementFormat = (vertexListMatch?.[1] ?? valueListMatch?.[1] ?? format) as VertexFormat; + const fixedSizeListFormat = getFixedSizeListFormatParts(format); + const elementFormat = (fixedSizeListFormat?.elementFormat ?? + vertexListMatch?.[1] ?? + valueListMatch?.[1] ?? + format) as VertexFormat; try { vertexFormatDecoder.getVertexFormatInfo(elementFormat); } catch { @@ -99,7 +127,12 @@ export function getGPUVectorFormatInfo(format: GPUVectorFormat): GPUVectorFormat const elementFormat = getGPUVectorElementFormat(format); const vertexList = isVertexListGPUVectorFormat(format); const valueList = isValueListGPUVectorFormat(format); + const fixedSizeListFormat = getFixedSizeListFormatParts(format); const vertexFormatInfo = vertexFormatDecoder.getVertexFormatInfo(elementFormat); + const byteLength = vertexFormatInfo.byteLength * (fixedSizeListFormat?.listSize ?? 1); + if (!Number.isSafeInteger(byteLength)) { + throw new Error(`Unsupported GPUVector format ${format}`); + } const type = vertexFormatInfo.type; const normalized = vertexFormatInfo.normalized; const primitiveType = getPrimitiveDataType(type, normalized); @@ -109,11 +142,14 @@ export function getGPUVectorFormatInfo(format: GPUVectorFormat): GPUVectorFormat elementFormat, vertexList, valueList, + fixedSizeList: Boolean(fixedSizeListFormat), + ...(fixedSizeListFormat ? {listSize: fixedSizeListFormat.listSize} : {}), type, signedDataType: getSignedDataType(elementFormat, type), primitiveType, components: vertexFormatInfo.components, - byteLength: vertexFormatInfo.byteLength, + elementByteLength: vertexFormatInfo.byteLength, + byteLength, integer: vertexFormatInfo.integer, signed: vertexFormatInfo.signed, normalized, @@ -127,6 +163,9 @@ export function isGPUVectorFormatCompatibleWithShaderType( shaderType: AttributeShaderType ): boolean { const formatInfo = getGPUVectorFormatInfo(format); + if (formatInfo.fixedSizeList) { + return false; + } const shaderTypeInfo = shaderTypeDecoder.getAttributeShaderTypeInfo(shaderType); if (formatInfo.components !== shaderTypeInfo.components) { @@ -147,6 +186,34 @@ export function isGPUVectorFormatCompatibleWithShaderType( } } +function getFixedSizeListFormatParts( + format: string +): {elementFormat: string; listSize: number} | undefined { + const fixedSizeListMatch = FIXED_SIZE_LIST_FORMAT_REGEXP.exec(format); + if (!fixedSizeListMatch) { + return undefined; + } + const listSize = Number(fixedSizeListMatch[2]); + if (!Number.isSafeInteger(listSize)) { + return undefined; + } + const elementFormat = fixedSizeListMatch[1]; + try { + const elementFormatInfo = vertexFormatDecoder.getVertexFormatInfo( + elementFormat as VertexFormat + ); + if ( + !Number.isSafeInteger(listSize * elementFormatInfo.components) || + !Number.isSafeInteger(listSize * elementFormatInfo.byteLength) + ) { + return undefined; + } + } catch { + return undefined; + } + return {elementFormat, listSize}; +} + function getPrimitiveDataType(type: NormalizedDataType, normalized: boolean): PrimitiveDataType { if (normalized) { return 'f32'; diff --git a/modules/tables/src/table/gpu-vector.ts b/modules/tables/src/table/gpu-vector.ts index cdfc8166fc..c8706212b3 100644 --- a/modules/tables/src/table/gpu-vector.ts +++ b/modules/tables/src/table/gpu-vector.ts @@ -30,7 +30,7 @@ export type GPUVectorFromBufferProps data: GPUData[]; /** Number of scalar values represented by one fixed row or flattened element. */ stride?: number; - /** Number of fixed rows or flattened vertex-list values across all chunks. */ + /** Number of fixed rows, fixed-list elements, or flattened variable-length values. */ valueLength?: number; /** Bytes between adjacent fixed rows or flattened elements. Defaults to the first chunk stride. */ byteStride?: number; @@ -148,7 +148,7 @@ export class GPUVector { readonly format?: T; /** Number of logical rows represented by the vector. */ length: number; - /** Number of fixed rows or flattened vertex-list values represented by the vector. */ + /** Number of fixed rows, fixed-list elements, or flattened variable-length values. */ valueLength: number; /** Number of scalar values represented by one fixed row or flattened element. */ readonly stride: number; @@ -179,11 +179,12 @@ export class GPUVector { buffer, format, length, - valueLength = length, + valueLength: explicitValueLength, byteOffset = 0, ownsBuffer = false } = props; - const {stride, byteStride, rowByteLength} = getResolvedGPUVectorLayout(props); + const {stride, byteStride, rowByteLength, listSize} = getResolvedGPUVectorLayout(props); + const valueLength = explicitValueLength ?? length * (listSize ?? 1); this.name = name; this.dataType = props.dataType; this.format = format; @@ -216,21 +217,27 @@ export class GPUVector { buffer, format, length, - valueLength = length, + valueLength: explicitValueLength, byteOffset = 0, byteStride, attributes, ownsBuffer = false } = props; + const formatInfo = format ? getGPUVectorFormatInfo(format) : undefined; + const valueLength = explicitValueLength ?? length * (formatInfo?.listSize ?? 1); + const stride = formatInfo?.fixedSizeList + ? formatInfo.components * formatInfo.listSize! + : byteStride; + const rowByteLength = formatInfo?.fixedSizeList ? formatInfo.byteLength : byteStride; this.name = name; this.dataType = props.dataType; this.format = format; this.length = length; this.valueLength = valueLength; - this.stride = byteStride; + this.stride = stride; this.byteOffset = byteOffset; this.byteStride = byteStride; - this.rowByteLength = byteStride; + this.rowByteLength = rowByteLength; this.bufferLayout = {name, byteStride, attributes}; this.data.push( new GPUData({ @@ -238,10 +245,10 @@ export class GPUVector { format, length, valueLength, - stride: byteStride, + stride, byteOffset, byteStride, - rowByteLength: byteStride, + rowByteLength, ownsBuffer, dataType: props.dataType }) @@ -255,7 +262,8 @@ export class GPUVector { const { name, data, - stride = data[0]?.stride ?? formatInfo?.components ?? 1, + stride = data[0]?.stride ?? + (formatInfo ? formatInfo.components * (formatInfo.listSize ?? 1) : 1), valueLength = data.reduce( (totalValueLength, chunk) => totalValueLength + chunk.valueLength, 0 @@ -411,16 +419,20 @@ function getResolvedGPUVectorLayout(props: { stride?: number; byteStride?: number; rowByteLength?: number; -}): {stride: number; byteStride: number; rowByteLength: number} { +}): {stride: number; byteStride: number; rowByteLength: number; listSize?: number} { const formatInfo = props.format ? getGPUVectorFormatInfo(props.format) : undefined; - const rowByteLength = props.rowByteLength ?? props.byteStride ?? formatInfo?.byteLength; + const rowByteLength = + props.rowByteLength ?? + (formatInfo?.fixedSizeList ? formatInfo.byteLength : props.byteStride) ?? + formatInfo?.byteLength; if (rowByteLength === undefined) { throw new Error('GPUVector requires format or explicit rowByteLength'); } return { - stride: props.stride ?? formatInfo?.components ?? 1, + stride: props.stride ?? (formatInfo ? formatInfo.components * (formatInfo.listSize ?? 1) : 1), byteStride: props.byteStride ?? rowByteLength, - rowByteLength + rowByteLength, + ...(formatInfo?.listSize ? {listSize: formatInfo.listSize} : {}) }; } diff --git a/modules/tables/test/table/gpu-constant.node.spec.ts b/modules/tables/test/table/gpu-constant.node.spec.ts index 2e3802e866..6282a9f5b2 100644 --- a/modules/tables/test/table/gpu-constant.node.spec.ts +++ b/modules/tables/test/table/gpu-constant.node.spec.ts @@ -24,6 +24,15 @@ test('GPUConstant validates and owns one fixed-width payload', t => { /requires exactly 8 bytes/, 'rejects incomplete rows' ); + t.throws( + () => + new GPUConstant({ + format: 'fixed-size-list' as never, + value: new Float32Array([1, 2, 3]) + }), + /cannot represent fixed-size-list storage columns/, + 'rejects unchecked fixed-size-list formats rather than treating storage rows as constants' + ); t.end(); }); diff --git a/modules/tables/test/table/gpu-data-types.node.spec.ts b/modules/tables/test/table/gpu-data-types.node.spec.ts index 87cd2d0e6f..0f84a00e3b 100644 --- a/modules/tables/test/table/gpu-data-types.node.spec.ts +++ b/modules/tables/test/table/gpu-data-types.node.spec.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors import {NullDevice} from '@luma.gl/test-utils'; -import {GPUData, type GPUDataView} from '@luma.gl/tables'; +import {GPUData, GPUVector, type FixedSizeList, type GPUDataView} from '@luma.gl/tables'; import {expectTypeOf, test} from 'vitest'; test('GPUData infers inline struct field types', () => { @@ -30,3 +30,24 @@ test('GPUData infers inline struct field types', () => { buffer.destroy(); }); + +test('GPUData and GPUVector preserve literal fixed-size-list formats', () => { + const device = new NullDevice({}); + const buffer = device.createBuffer({byteLength: 32}); + const data = new GPUData({ + buffer, + length: 2, + format: 'fixed-size-list' + }); + const vector = new GPUVector({ + type: 'data', + name: 'embeddings', + data: [data] + }); + + expectTypeOf(data.format).toEqualTypeOf | undefined>(); + expectTypeOf(vector.format).toEqualTypeOf | undefined>(); + + vector.destroy(); + buffer.destroy(); +}); diff --git a/modules/tables/test/table/gpu-table-computation.node.spec.ts b/modules/tables/test/table/gpu-table-computation.node.spec.ts new file mode 100644 index 0000000000..777a68ddbc --- /dev/null +++ b/modules/tables/test/table/gpu-table-computation.node.spec.ts @@ -0,0 +1,95 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import type {Binding, Device} from '@luma.gl/core'; +import {GPUData, GPUTableComputation, GPUVector} from '@luma.gl/tables'; +import {NullDevice} from '@luma.gl/test-utils'; +import {expect, test, vi} from 'vitest'; + +vi.mock('@luma.gl/engine', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + Computation: class { + readonly device: Device; + bindings: Record; + + constructor(device: Device, props: {bindings?: Record}) { + this.device = device; + this.bindings = props.bindings ?? {}; + } + + setBindings(bindings: Record): void { + Object.assign(this.bindings, bindings); + } + } + }; +}); + +test('GPUTableComputation binds fixed-size-list rows without trailing physical padding', () => { + const device = new NullDevice({}); + const embeddings = new GPUVector({ + type: 'buffer', + name: 'embeddings', + buffer: device.createBuffer({byteLength: 32}), + format: 'fixed-size-list', + length: 2, + byteOffset: 4, + byteStride: 16, + ownsBuffer: true + }); + const computation = new GPUTableComputation(device, {inputVectors: {embeddings}}); + + expect(computation.bindings.embeddings).toEqual({ + buffer: embeddings.data[0].buffer, + offset: 4, + size: 28 + }); + + embeddings.destroy(); +}); + +test('GPUTableComputation preserves explicit value spans and empty fixed-size-list chunks', () => { + const device = new NullDevice({}); + const limitedData = new GPUData({ + buffer: device.createBuffer({byteLength: 28}), + format: 'fixed-size-list', + length: 2, + byteStride: 16, + valueByteLength: 24, + ownsBuffer: true + }); + const limited = new GPUVector({ + type: 'data', + name: 'limited', + data: [limitedData], + ownsData: false + }); + const empty = new GPUVector({ + type: 'buffer', + name: 'empty', + buffer: device.createBuffer({byteLength: 4}), + format: 'fixed-size-list', + length: 0, + byteOffset: 4, + ownsBuffer: true + }); + const limitedComputation = new GPUTableComputation(device, {inputVectors: {limited}}); + const emptyComputation = new GPUTableComputation(device, {inputVectors: {empty}}); + + expect(limitedComputation.bindings.limited).toEqual({ + buffer: limitedData.buffer, + offset: 0, + size: 24 + }); + expect(emptyComputation.bindings.empty).toEqual({ + buffer: empty.data[0].buffer, + offset: 4, + size: 0 + }); + + limited.destroy(); + limitedData.destroy(); + empty.destroy(); +}); diff --git a/modules/tables/test/table/gpu-table-shader-bindings.node.spec.ts b/modules/tables/test/table/gpu-table-shader-bindings.node.spec.ts index 2c60d911cc..9028cdc763 100644 --- a/modules/tables/test/table/gpu-table-shader-bindings.node.spec.ts +++ b/modules/tables/test/table/gpu-table-shader-bindings.node.spec.ts @@ -91,6 +91,55 @@ test('GPUTableShaderBindings resolves draw-ready buffers per preserved batch', t t.end(); }); +test('GPUTableShaderBindings binds complete fixed-size-list rows without trailing padding', t => { + const device = new NullDevice({}); + const embeddings = new GPUVector({ + type: 'buffer', + name: 'embeddings', + buffer: device.createBuffer({byteLength: 32}), + format: 'fixed-size-list', + length: 2, + byteOffset: 4, + byteStride: 16, + ownsBuffer: true + }); + const table = new GPUTable({vectors: {embeddings}}); + const gpuInputSchema = [ + { + columnName: 'embeddings', + storageBindingName: 'embeddings', + kind: 'scalars', + required: true, + formats: ['fixed-size-list'] + } + ] as const satisfies GPUInputSchema; + const shaderBindings = new GPUTableShaderBindings(device, { + table, + gpuInputSchema, + shaderLayout: { + attributes: [], + bindings: [{name: 'embeddings', type: 'read-only-storage', group: 0, location: 0}] + } + }); + + t.equal(embeddings.valueLength, 6, 'logical rows expose flattened scalar element counts'); + t.equal(embeddings.rowByteLength, 12, 'row payload excludes physical padding'); + t.deepEqual(shaderBindings.bufferLayout, [], 'storage-only inputs have no vertex attributes'); + t.deepEqual( + shaderBindings.batches[0].bindings.embeddings, + { + buffer: embeddings.data[0].buffer, + offset: 4, + size: 28 + }, + 'storage binding includes row padding but does not require nonexistent final-row padding' + ); + + shaderBindings.destroy(); + table.destroy(); + t.end(); +}); + test('GPUTableShaderBindings validates schema formats', t => { const device = new NullDevice({}); const table = new GPUTable({ diff --git a/modules/tables/test/table/gpu-vector-format.node.spec.ts b/modules/tables/test/table/gpu-vector-format.node.spec.ts index 4198b1190c..6cfcf8e053 100644 --- a/modules/tables/test/table/gpu-vector-format.node.spec.ts +++ b/modules/tables/test/table/gpu-vector-format.node.spec.ts @@ -13,6 +13,7 @@ import { getGPUVectorFormatInfo, getGPUVectorData, getRequiredGPUVector, + isFixedSizeListGPUVectorFormat, isGPUVectorFormatCompatibleWithShaderType, isValueListGPUVectorFormat, isVertexListGPUVectorFormat @@ -23,10 +24,14 @@ test('GPUVector format helpers parse fixed and variable-length formats', t => { const fixedInfo = getGPUVectorFormatInfo('float32x3'); const vertexListInfo = getGPUVectorFormatInfo('vertex-list'); const valueListInfo = getGPUVectorFormatInfo('value-list'); + const fixedSizeListInfo = getGPUVectorFormatInfo('fixed-size-list'); + const fixedSizeVectorListInfo = getGPUVectorFormatInfo('fixed-size-list'); t.equal(fixedInfo.elementFormat, 'float32x3', 'fixed vector element format is unchanged'); t.equal(fixedInfo.vertexList, false, 'fixed vector is not a vertex list'); t.equal(fixedInfo.valueList, false, 'fixed vector is not a value list'); + t.equal(fixedInfo.fixedSizeList, false, 'fixed vector is not a fixed-size list'); + t.equal(fixedInfo.elementByteLength, 12, 'fixed vector element byte length is decoded'); t.equal(fixedInfo.byteLength, 12, 'fixed vector byte length is decoded'); t.equal(vertexListInfo.elementFormat, 'unorm8x4', 'vertex-list exposes its element format'); t.equal(vertexListInfo.vertexList, true, 'vertex-list marker is decoded'); @@ -35,10 +40,30 @@ test('GPUVector format helpers parse fixed and variable-length formats', t => { t.equal(valueListInfo.elementFormat, 'uint8', 'value-list exposes its element format'); t.equal(valueListInfo.vertexList, false, 'value-list is not a vertex-list'); t.equal(valueListInfo.valueList, true, 'value-list marker is decoded'); + t.equal(fixedSizeListInfo.elementFormat, 'float32', 'fixed-size list exposes its element format'); + t.equal(fixedSizeListInfo.fixedSizeList, true, 'fixed-size-list marker is decoded'); + t.equal(fixedSizeListInfo.vertexList, false, 'fixed-size list is not a vertex list'); + t.equal( + fixedSizeListInfo.valueList, + false, + 'fixed-size list is not a variable-length value list' + ); + t.equal(fixedSizeListInfo.listSize, 768, 'fixed-size list exposes its logical row cardinality'); + t.equal(fixedSizeListInfo.components, 1, 'fixed-size list preserves scalar element components'); + t.equal(fixedSizeListInfo.elementByteLength, 4, 'fixed-size list exposes element byte length'); + t.equal(fixedSizeListInfo.byteLength, 3072, 'fixed-size list byte length describes one full row'); + t.equal(fixedSizeVectorListInfo.components, 3, 'vector-valued fixed lists retain element shape'); + t.equal(fixedSizeVectorListInfo.elementByteLength, 12, 'vector-valued lists expose element size'); + t.equal(fixedSizeVectorListInfo.byteLength, 24, 'vector-valued lists expose complete row size'); t.equal(getGPUVectorElementFormat('vertex-list'), 'unorm8x4'); t.equal(getGPUVectorElementFormat('value-list'), 'uint8'); + t.equal(getGPUVectorElementFormat('fixed-size-list'), 'float32'); t.ok(isVertexListGPUVectorFormat('vertex-list'), 'recognizes vertex-list syntax'); t.ok(isValueListGPUVectorFormat('value-list'), 'recognizes value-list syntax'); + t.ok( + isFixedSizeListGPUVectorFormat('fixed-size-list'), + 'recognizes canonical fixed-size-list syntax' + ); t.notOk(isVertexListGPUVectorFormat('list'), 'generic list syntax is not accepted'); t.throws( () => getGPUVectorFormatInfo('list' as never), @@ -49,6 +74,47 @@ test('GPUVector format helpers parse fixed and variable-length formats', t => { t.end(); }); +test('GPUVector fixed-size-list formats require canonical positive safe cardinalities', t => { + const invalidFormats = [ + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-size-list', + 'fixed-list' + ]; + + for (const invalidFormat of invalidFormats) { + t.notOk( + isFixedSizeListGPUVectorFormat(invalidFormat), + `rejects noncanonical fixed-size-list syntax ${invalidFormat}` + ); + t.throws( + () => getGPUVectorFormatInfo(invalidFormat as never), + /Unsupported GPUVector format/, + `cannot decode invalid fixed-size-list format ${invalidFormat}` + ); + } + t.throws( + () => getGPUVectorFormatInfo('fixed-size-list'), + /Unsupported GPUVector format/, + 'rejects fixed-size-list rows whose physical byte length exceeds a safe integer' + ); + t.throws( + () => getGPUVectorFormatInfo('fixed-size-list' as never), + /Unsupported GPUVector format/, + 'rejects an unsupported fixed-size-list element format' + ); + + t.end(); +}); + test('GPUVector format helpers validate shader compatibility', t => { t.ok( isGPUVectorFormatCompatibleWithShaderType('unorm8x4', 'vec4'), @@ -66,7 +132,215 @@ test('GPUVector format helpers validate shader compatibility', t => { isGPUVectorFormatCompatibleWithShaderType('float32x3', 'vec4'), 'component mismatch is rejected' ); + t.notOk( + isGPUVectorFormatCompatibleWithShaderType('fixed-size-list', 'f32'), + 'fixed-size-list storage columns never masquerade as vertex shader attributes' + ); + + t.end(); +}); + +test('GPUData derives complete fixed-size-list row and flattened-value metadata', t => { + const device = new NullDevice({}); + const packedData = new GPUData({ + buffer: device.createBuffer({byteLength: 2 * 768 * Float32Array.BYTES_PER_ELEMENT}), + format: 'fixed-size-list', + length: 2, + ownsBuffer: true + }); + const paddedData = new GPUData({ + buffer: device.createBuffer({byteLength: 6176}), + format: 'fixed-size-list', + length: 2, + byteStride: 3104, + ownsBuffer: true + }); + const vectorElementData = new GPUData({ + buffer: device.createBuffer({byteLength: 24}), + format: 'fixed-size-list', + length: 1, + ownsBuffer: true + }); + const emptyData = new GPUData({ + buffer: device.createBuffer({byteLength: 0}), + format: 'fixed-size-list', + length: 0, + ownsBuffer: true + }); + + t.equal(packedData.length, 2, 'length remains the logical row count'); + t.equal(packedData.valueLength, 1536, 'valueLength counts flattened fixed-list elements'); + t.equal(packedData.stride, 768, 'stride counts scalar components in one logical row'); + t.equal(packedData.rowByteLength, 3072, 'row payload spans all fixed-list elements'); + t.equal(packedData.byteStride, 3072, 'packed row stride defaults to the complete row payload'); + t.equal(paddedData.rowByteLength, 3072, 'padding does not change the logical row payload'); + t.equal(paddedData.byteStride, 3104, 'explicit padded row stride is preserved'); + t.equal(vectorElementData.valueLength, 2, 'vector-valued lists count flattened vector elements'); + t.equal(vectorElementData.stride, 6, 'vector-valued rows count every scalar component'); + t.equal(vectorElementData.rowByteLength, 24, 'vector-valued rows span their complete payload'); + t.equal(emptyData.valueLength, 0, 'empty fixed-size lists expose no flattened values'); + t.equal(emptyData.rowByteLength, 1536, 'empty fixed-size lists retain their complete row format'); + + packedData.destroy(); + paddedData.destroy(); + vectorElementData.destroy(); + emptyData.destroy(); + t.end(); +}); + +test('GPUData rejects malformed fixed-size-list row layouts and out-of-range views', t => { + const device = new NullDevice({}); + const buffer = device.createBuffer({byteLength: 28}); + const format = 'fixed-size-list' as const; + + t.throws( + () => new GPUData({buffer, format, length: 2, valueLength: 5}), + /valueLength must equal its flattened row elements/, + 'rejects flattened counts that do not match fixed row cardinality' + ); + t.throws( + () => new GPUData({buffer, format, length: 1, stride: 2}), + /stride cannot truncate its row components/, + 'rejects scalar strides smaller than the fixed row cardinality' + ); + t.throws( + () => new GPUData({buffer, format, length: 1, rowByteLength: 8}), + /rowByteLength cannot truncate its row payload/, + 'rejects row payloads that omit fixed-list elements' + ); + t.throws( + () => new GPUData({buffer, format, length: 2, byteStride: 8}), + /byteStride cannot overlap its row payload/, + 'rejects row strides that overlap adjacent fixed-list rows' + ); + t.throws( + () => new GPUData({buffer, format, length: 2, byteOffset: 5}), + /exceeds its backing buffer byte length/, + 'rejects fixed-list ranges that run beyond the physical allocation' + ); + t.throws( + () => new GPUData({buffer, format, length: 1, byteOffset: -1}), + /safe non-negative integers/, + 'rejects negative row byte offsets' + ); + t.throws( + () => new GPUData({buffer, format, length: 2, byteStride: Number.MAX_SAFE_INTEGER}), + /byte range must use safe integers/, + 'rejects final-row spans that overflow safe integer arithmetic' + ); + t.throws( + () => new GPUData({buffer, format, length: Number.MAX_SAFE_INTEGER}), + /safe non-negative integers/, + 'rejects flattened element counts that overflow safe integer arithmetic' + ); + + const paddedData = new GPUData({buffer, format, length: 2, byteStride: 16}); + t.equal(paddedData.rowByteLength, 12, 'accepts padded rows without requiring final-row padding'); + paddedData.destroy(); + buffer.destroy(); + t.end(); +}); + +test('GPUVector preserves fixed-size-list rows, padded layouts, and source chunks', t => { + const device = new NullDevice({}); + const packedVector = new GPUVector({ + type: 'buffer', + name: 'embeddings', + buffer: device.createBuffer({byteLength: 3 * 384 * Float32Array.BYTES_PER_ELEMENT}), + format: 'fixed-size-list', + length: 3, + ownsBuffer: true + }); + const paddedVector = new GPUVector({ + type: 'buffer', + name: 'paddedEmbeddings', + buffer: device.createBuffer({byteLength: 3104 + 3072}), + format: 'fixed-size-list', + length: 2, + byteStride: 3104, + ownsBuffer: true + }); + const firstChunk = new GPUData({ + buffer: device.createBuffer({byteLength: 1536}), + format: 'fixed-size-list', + length: 1, + ownsBuffer: true + }); + const secondChunk = new GPUData({ + buffer: device.createBuffer({byteLength: 3072}), + format: 'fixed-size-list', + length: 2, + ownsBuffer: true + }); + const chunkedVector = new GPUVector({ + type: 'data', + name: 'chunkedEmbeddings', + data: [firstChunk, secondChunk], + ownsData: false + }); + + t.equal(packedVector.length, 3, 'buffer-backed vectors retain logical rows'); + t.equal(packedVector.valueLength, 1152, 'buffer-backed vectors count flattened elements'); + t.equal(packedVector.stride, 384, 'buffer-backed vectors derive scalar row stride'); + t.equal(packedVector.byteStride, 1536, 'buffer-backed vectors derive complete row bytes'); + t.equal(paddedVector.rowByteLength, 3072, 'padded vectors retain the actual row payload'); + t.equal(paddedVector.byteStride, 3104, 'padded vectors retain explicit physical row stride'); + t.equal(chunkedVector.length, 3, 'chunk-backed vectors aggregate logical rows'); + t.equal(chunkedVector.valueLength, 1152, 'chunk-backed vectors aggregate flattened elements'); + t.equal(chunkedVector.data.length, 2, 'chunk-backed vectors preserve source chunk boundaries'); + t.equal( + chunkedVector.data[0], + firstChunk, + 'chunk-backed vectors borrow the original first chunk' + ); + t.equal( + chunkedVector.data[1], + secondChunk, + 'chunk-backed vectors borrow the original second chunk' + ); + + chunkedVector.destroy(); + t.notOk(firstChunk.buffer.destroyed, 'borrowed chunk ownership remains with the original owner'); + packedVector.destroy(); + paddedVector.destroy(); + firstChunk.destroy(); + secondChunk.destroy(); + t.end(); +}); + +test('Appendable GPUVector preserves fixed-size-list rows without implicit packing', t => { + const device = new NullDevice({}); + const embeddings = new GPUVector({ + type: 'appendable', + name: 'embeddings', + device, + format: 'fixed-size-list' + }); + const firstChunk = new GPUData({ + buffer: device.createBuffer({byteLength: 1536}), + format: 'fixed-size-list', + length: 1, + ownsBuffer: true + }); + const secondChunk = new GPUData({ + buffer: device.createBuffer({byteLength: 3072}), + format: 'fixed-size-list', + length: 2, + ownsBuffer: true + }); + + embeddings.appendDataChunk(firstChunk); + embeddings.appendDataChunk(secondChunk); + + t.equal(embeddings.length, 3, 'appendable fixed-size lists count logical rows'); + t.equal(embeddings.valueLength, 1152, 'appendable fixed-size lists count flattened elements'); + t.equal(embeddings.byteStride, 1536, 'appendable fixed-size lists retain full-row byte stride'); + t.equal(embeddings.data.length, 2, 'appending preserves separately owned source chunks'); + + embeddings.destroy(); + t.ok(firstChunk.buffer.destroyed, 'appendable vector destroys the first owned chunk'); + t.ok(secondChunk.buffer.destroyed, 'appendable vector destroys the second owned chunk'); t.end(); }); @@ -92,6 +366,299 @@ test('GPUVector accepts format as canonical metadata and synthesizes table layou t.end(); }); +test('GPU tables preserve explicit fixed-size-list attribute expansion', t => { + const device = new NullDevice({}); + const embeddings = new GPUVector({ + type: 'interleaved', + name: 'embeddings', + buffer: device.createBuffer({byteLength: 32}), + format: 'fixed-size-list', + length: 2, + byteStride: 16, + attributes: [ + {attribute: 'embeddingPart0', format: 'float32x2', byteOffset: 0}, + {attribute: 'embeddingPart1', format: 'float32x2', byteOffset: 8} + ], + ownsBuffer: true + }); + const table = new GPUTable({vectors: {embeddings}}); + + t.equal(embeddings.valueLength, 8, 'explicitly expanded columns retain flattened element counts'); + t.equal(embeddings.stride, 4, 'explicitly expanded columns retain scalar row cardinality'); + t.equal(table.bufferLayout.length, 1, 'retains the caller-owned explicit attribute layout'); + t.deepEqual( + table.bufferLayout[0].attributes?.map(attribute => attribute.attribute), + ['embeddingPart0', 'embeddingPart1'], + 'does not replace an adapter-provided attribute expansion' + ); + + table.destroy(); + t.end(); +}); + +test('GPU tables retain fixed-size-list storage columns without synthetic vertex attributes', t => { + const device = new NullDevice({}); + const embeddings = new GPUVector({ + type: 'buffer', + name: 'embeddings', + buffer: device.createBuffer({byteLength: 2 * 1536 * Float32Array.BYTES_PER_ELEMENT}), + format: 'fixed-size-list', + length: 2, + ownsBuffer: true + }); + const identifiers = new GPUVector({ + type: 'buffer', + name: 'identifiers', + buffer: device.createBuffer({byteLength: 2 * Uint32Array.BYTES_PER_ELEMENT}), + format: 'uint32', + length: 2, + ownsBuffer: true + }); + const table = new GPUTable({vectors: {embeddings, identifiers}}); + + t.equal(table.numRows, 2, 'fixed-size-list vector lengths remain table row counts'); + t.equal(table.gpuVectors.embeddings.valueLength, 3072, 'table retains flattened element counts'); + t.equal( + table.schema.fields.find(field => field.name === 'embeddings')?.format, + 'fixed-size-list', + 'schema retains the complete fixed-size-list memory format' + ); + t.deepEqual( + table.bufferLayout.map(layout => layout.name), + ['identifiers'], + 'only vertex-compatible columns receive synthesized buffer layouts' + ); + t.equal( + table.batches[0].gpuData.embeddings.format, + 'fixed-size-list', + 'record batches retain row-aligned storage columns' + ); + + table.destroy(); + t.end(); +}); + +test('GPU tables preserve fixed-size-list batches until explicitly packed', t => { + const device = new NullDevice({}); + const firstBatch = new GPURecordBatch({ + gpuData: { + embeddings: new GPUData({ + buffer: device.createBuffer({byteLength: 1536}), + format: 'fixed-size-list', + length: 1, + ownsBuffer: true + }) + } + }); + const secondBatch = new GPURecordBatch({ + gpuData: { + embeddings: new GPUData({ + buffer: device.createBuffer({byteLength: 3072}), + format: 'fixed-size-list', + length: 2, + ownsBuffer: true + }) + } + }); + const table = new GPUTable({batches: [firstBatch, secondBatch]}); + + t.equal(table.batches.length, 2, 'table construction preserves source batch boundaries'); + t.equal(table.numRows, 3, 'preserved batches contribute logical rows'); + t.equal( + table.gpuVectors.embeddings.valueLength, + 1152, + 'aggregate vector tracks flattened values' + ); + t.equal(table.gpuVectors.embeddings.data.length, 2, 'aggregate vector borrows both batch chunks'); + + table.packBatches(); + + t.equal( + table.batches.length, + 1, + 'only explicit packing combines adjacent fixed-size-list batches' + ); + t.equal(table.numRows, 3, 'explicit packing preserves logical rows'); + t.equal( + table.gpuVectors.embeddings.valueLength, + 1152, + 'explicit packing preserves list elements' + ); + t.equal(table.gpuVectors.embeddings.data.length, 1, 'explicit packing creates one owned chunk'); + t.equal( + table.batches[0].gpuData.embeddings.byteStride, + 1536, + 'explicit packing preserves complete fixed-size-list row stride' + ); + + table.destroy(); + t.end(); +}); + +test('GPU tables preserve variable-length packing errors before validating adapter metadata', t => { + const device = new NullDevice({}); + const batches = [0, 1].map( + () => + new GPURecordBatch({ + gpuData: { + texts: new GPUData({ + buffer: device.createBuffer({byteLength: 5}), + format: 'value-list', + length: 1, + valueLength: 5, + byteStride: 1, + rowByteLength: 1, + readbackMetadata: {adapter: 'utf8'}, + ownsBuffer: true + }) + }, + bufferLayout: [] + }) + ); + const table = new GPUTable({batches}); + + t.throws( + () => table.packBatches(), + /does not support variable-length GPUData "texts"/, + 'retains the existing variable-length rejection before inspecting adapter metadata' + ); + t.equal(table.batches.length, 2, 'failed packing leaves both source batches unchanged'); + + table.destroy(); + t.end(); +}); + +test('GPU tables reject packing nullable fixed-size-list rows and scalar source identifiers', t => { + const device = new NullDevice({}); + const firstEmbeddings = new GPUData({ + buffer: device.createBuffer({byteLength: 12}), + format: 'fixed-size-list', + length: 1, + nullBitmap: new Uint8Array([0]), + ownsBuffer: true + }); + const secondEmbeddings = new GPUData({ + buffer: device.createBuffer({byteLength: 12}), + format: 'fixed-size-list', + length: 1, + ownsBuffer: true + }); + const nullableEmbeddings = new GPUTable({ + batches: [ + new GPURecordBatch({gpuData: {embeddings: firstEmbeddings}}), + new GPURecordBatch({gpuData: {embeddings: secondEmbeddings}}) + ] + }); + + t.throws( + () => nullableEmbeddings.packBatches(), + /cannot preserve null or readback metadata.*embeddings/, + 'does not silently erase nullable fixed-size-list row eligibility' + ); + t.equal(nullableEmbeddings.batches.length, 2, 'failed packing preserves every source batch'); + t.deepEqual( + Array.from(nullableEmbeddings.batches[0].gpuData.embeddings.nullBitmap ?? []), + [0], + 'failed packing retains normalized fixed-size-list validity metadata' + ); + + const firstSourceIdentifiers = new GPUData({ + buffer: device.createBuffer({byteLength: 4}), + format: 'uint32', + length: 1, + nullBitmap: new Uint8Array([0]), + readbackMetadata: {adapter: 'numeric-validity'}, + ownsBuffer: true + }); + const secondSourceIdentifiers = new GPUData({ + buffer: device.createBuffer({byteLength: 4}), + format: 'uint32', + length: 1, + ownsBuffer: true + }); + const nullableIdentifiers = new GPUTable({ + batches: [ + new GPURecordBatch({gpuData: {sourceIdentifiers: firstSourceIdentifiers}}), + new GPURecordBatch({gpuData: {sourceIdentifiers: secondSourceIdentifiers}}) + ] + }); + + t.throws( + () => nullableIdentifiers.packBatches(), + /cannot preserve null or readback metadata.*sourceIdentifiers/, + 'does not turn null stable source identifiers into valid physical zero values' + ); + t.equal( + nullableIdentifiers.batches.length, + 2, + 'failed identifier packing preserves both batches' + ); + t.equal( + nullableIdentifiers.batches[0].gpuData.sourceIdentifiers.readbackMetadata?.adapter, + 'numeric-validity', + 'failed packing retains adapter-owned numeric readback metadata' + ); + + nullableEmbeddings.destroy(); + nullableIdentifiers.destroy(); + t.end(); +}); + +test('GPU tables reject packing adapter readback metadata even without a row bitmap', t => { + const device = new NullDevice({}); + const firstData = new GPUData({ + buffer: device.createBuffer({byteLength: 4}), + format: 'uint32', + length: 1, + readbackMetadata: {adapter: 'custom-source-metadata'}, + ownsBuffer: true + }); + const secondData = new GPUData({ + buffer: device.createBuffer({byteLength: 4}), + format: 'uint32', + length: 1, + ownsBuffer: true + }); + const table = new GPUTable({ + batches: [ + new GPURecordBatch({gpuData: {values: firstData}}), + new GPURecordBatch({gpuData: {values: secondData}}) + ] + }); + + t.throws( + () => table.packBatches(), + /cannot preserve null or readback metadata.*values/, + 'generic tables never silently discard adapter-owned reconstruction metadata' + ); + t.equal(table.batches.length, 2, 'metadata rejection occurs before any batch is replaced'); + + table.destroy(); + t.end(); +}); + +test('GPURecordBatch synthesizes fixed-size-list schemas without vertex layouts', t => { + const device = new NullDevice({}); + const embeddings = new GPUData({ + buffer: device.createBuffer({byteLength: 2 * 768 * Float32Array.BYTES_PER_ELEMENT}), + format: 'fixed-size-list', + length: 2, + ownsBuffer: true + }); + const batch = new GPURecordBatch({gpuData: {embeddings}}); + const table = new GPUTable({batches: [batch]}); + + t.equal(batch.numRows, 2, 'record batches infer logical fixed-size-list rows'); + t.equal(batch.gpuData.embeddings.valueLength, 1536, 'record batches retain flattened values'); + t.equal(batch.schema.fields[0].format, 'fixed-size-list', 'schema keeps row shape'); + t.deepEqual(batch.bufferLayout, [], 'storage-only batches have no synthetic vertex layouts'); + t.deepEqual(table.bufferLayout, [], 'storage-only tables have no synthetic vertex layouts'); + t.equal(table.gpuVectors.embeddings.length, 2, 'aggregate vectors retain logical rows'); + + table.destroy(); + t.end(); +}); + test('GPUTable rejects vertex-list vectors without adapter-specific layout handling', t => { const device = new NullDevice({}); const colors = new GPUVector({