• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / luma.gl / 28183428603

25 Jun 2026 04:01PM UTC coverage: 70.395%. First build
28183428603

Pull #2695

github

web-flow
Merge 35d924ad1 into 53f8e0f7b
Pull Request #2695: feat(tables): add GPU constant columns

9740 of 15682 branches covered (62.11%)

Branch coverage included in aggregate %.

245 of 321 new or added lines in 6 files covered. (76.32%)

19474 of 25818 relevant lines covered (75.43%)

4230.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.16
/modules/tables/src/table/gpu-table.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {Buffer, type BufferLayout, type VertexFormat} from '@luma.gl/core';
6
import {DynamicBuffer} from '@luma.gl/engine';
7
import type {GPUField, GPUSchema, GPUTypeMap} from './gpu-schema';
8
import {GPUConstant} from './gpu-constant';
9
import {GPUData} from './gpu-data';
10
import {GPUVector} from './gpu-vector';
11
import {GPURecordBatch, type GPURecordBatchSourceInfo} from './gpu-record-batch';
12
import {GPU_TABLE_INDEX_COLUMN_NAME, isGPUTableIndexColumnName} from './gpu-schema';
13
import {
14
  getGPUVectorElementFormat,
15
  type GPUVectorFormat,
16
  isValueListGPUVectorFormat,
17
  isVertexListGPUVectorFormat
18
} from './gpu-vector-format';
19

20
type GPUVectorMap<T extends GPUTypeMap = GPUTypeMap> = {
21
  [Name in keyof T & string]: GPUVector<T[Name]>;
22
};
23

24
/** One logical GPU table column with a format-preserving constant option for fixed-width rows. */
25
export type GPUColumn<T extends GPUVectorFormat = GPUVectorFormat> =
26
  | GPUVector<T>
27
  | (T extends VertexFormat ? GPUConstant<T> : never);
28

29
/** Typed logical GPU table columns keyed by schema field name. */
30
export type GPUColumnMap<T extends GPUTypeMap = GPUTypeMap> = {
31
  [Name in keyof T & string]: GPUColumn<T[Name]>;
32
};
33

34
/** Options for constructing a GPU table from logical varying and constant columns. */
35
export type GPUTableFromColumnsProps<T extends GPUTypeMap = GPUTypeMap> = {
36
  /** Logical columns keyed by table field name. */
37
  columns: GPUColumnMap<T> | Record<string, GPUVector | GPUConstant>;
38
  /** Required logical row count when every column is constant. */
39
  numRows?: number;
40
  /** Optional table-level schema metadata. */
41
  metadata?: Map<string, string>;
42
  /** Optional source-row identity forwarded when vectors contain one GPUData chunk. */
43
  sourceInfo?: GPURecordBatchSourceInfo;
44
  /** Number of null rows forwarded when vectors contain one GPUData chunk. */
45
  nullCount?: number;
46
};
47

48
/** Options for constructing a GPU table from existing GPU vectors. */
49
export type GPUTableFromVectorsProps<T extends GPUTypeMap = GPUTypeMap> = {
50
  /** GPU vectors keyed by name, or a list of named GPU vectors. */
51
  vectors: GPUVectorMap<T> | Record<string, GPUVector> | GPUVector[];
52
  /** Optional table-level schema metadata. */
53
  metadata?: Map<string, string>;
54
  /** Optional source-row identity forwarded when vectors contain one GPUData chunk. */
55
  sourceInfo?: GPURecordBatchSourceInfo;
56
  /** Number of null rows forwarded when vectors contain one GPUData chunk. */
57
  nullCount?: number;
58
};
59

60
/** Options for constructing a GPU table from already-built record batches. */
61
export type GPUTableFromBatchesProps<T extends GPUTypeMap = GPUTypeMap> = {
62
  /** GPU batches preserved by the table. */
63
  batches: GPURecordBatch<T>[];
64
  /** Immutable logical constant columns shared by every batch. */
65
  constants?: Record<string, GPUConstant>;
66
};
67

68
/** Options for constructing one typed table with no GPU record batches. */
69
export type GPUTableFromSchemaProps<T extends GPUTypeMap = GPUTypeMap> = {
70
  /** Selected schema retained by the empty table. */
71
  schema: GPUSchema<T>;
72
  /** Optional layout retained until the first batch is added. */
73
  bufferLayout?: BufferLayout[];
74
};
75

76
/** Generic GPU table construction props. */
77
export type GPUTableProps<T extends GPUTypeMap = GPUTypeMap> =
78
  | GPUTableFromColumnsProps<T>
79
  | GPUTableFromVectorsProps<T>
80
  | GPUTableFromBatchesProps<T>
81
  | GPUTableFromSchemaProps<T>;
82

83
/** Options for replacing preserved GPU batches with larger packed batches. */
84
export type GPUTablePackBatchesOptions = {
85
  /** Greedily merge adjacent batches until each emitted batch reaches this row count. */
86
  minBatchSize?: number;
87
};
88

89
/** Half-open batch index range used by {@link GPUTable.detachBatches}. */
90
export type GPUTableDetachBatchesOptions = {
91
  /** First batch index to detach. Defaults to `0`. */
92
  first?: number;
93
  /** Batch index after the last detached batch. Defaults to `batches.length`. */
94
  last?: number;
95
};
96

97
/** GPU memory and schema metadata for one logical table. */
98
export class GPUTable<T extends GPUTypeMap = GPUTypeMap> {
99
  /** GPU-facing schema for the selected columns. */
100
  schema: GPUSchema<T>;
101
  /** Number of logical rows in the table. */
102
  numRows: number;
103
  /** Number of selected GPU columns in {@link schema}. */
104
  numCols: number;
105
  /** Number of null rows retained in table metadata. */
106
  nullCount: number;
107
  /** GPU vectors keyed by table/shader column name. */
108
  readonly gpuVectors: Record<string, GPUVector> = {};
81✔
109
  /** Immutable logical constants keyed by table/shader column name. */
110
  readonly gpuConstants: Record<string, GPUConstant> = {};
81✔
111
  /** Canonical logical columns keyed by table/shader column name. */
112
  readonly gpuColumns: Record<string, GPUVector | GPUConstant> = {};
81✔
113
  /** Preserved batch-local GPU storage. */
114
  readonly batches: GPURecordBatch[] = [];
81✔
115

116
  /** Buffer layout shared by preserved record batches. */
117
  readonly bufferLayout: BufferLayout[] = [];
81✔
118

119
  /** Creates one logical GPU table from a schema, batches, or batch-aligned vectors. */
120
  constructor(props: GPUTableProps<T>) {
121
    if ('batches' in props) {
81✔
122
      const firstBatch = props.batches[0];
65✔
123
      if (!firstBatch) {
65!
124
        throw new Error('GPUTable batches constructor requires at least one GPURecordBatch');
×
125
      }
126
      assertCompatibleGPURecordBatches(props.batches);
65✔
127
      Object.assign(this.gpuConstants, props.constants ?? {});
65✔
128
      for (const constantName of Object.keys(this.gpuConstants)) {
65✔
NEW
129
        if (firstBatch.gpuData[constantName]) {
×
NEW
130
          throw new Error(
×
131
            `GPUTable constant column "${constantName}" conflicts with batch GPUData`
132
          );
133
        }
NEW
134
        if (isGPUTableIndexColumnName(constantName)) {
×
NEW
135
          throw new Error('GPUTable reserved index column "indices" cannot be constant');
×
136
        }
137
      }
138
      const constantFields = getGPUConstantFields(this.gpuConstants);
65✔
139
      this.schema =
65✔
140
        constantFields.length === 0
65!
141
          ? (firstBatch.schema as GPUSchema<T>)
142
          : {
143
              fields: [...firstBatch.schema.fields, ...constantFields] as GPUField<
144
                keyof T & string
145
              >[],
146
              metadata: new Map(firstBatch.schema.metadata)
147
            };
148
      this.numCols = this.schema.fields.length;
65✔
149
      this.batches.push(...(props.batches as GPURecordBatch[]));
65✔
150
      this.bufferLayout.push(...firstBatch.bufferLayout);
65✔
151
      this.numRows = props.batches.reduce((numRows, batch) => numRows + batch.numRows, 0);
81✔
152
      this.nullCount = props.batches.reduce((nullCount, batch) => nullCount + batch.nullCount, 0);
81✔
153
      this.rebuildAggregateVectors();
65✔
154
      return;
65✔
155
    }
156

157
    if ('schema' in props) {
16!
158
      this.schema = props.schema;
×
159
      this.numCols = props.schema.fields.length;
×
160
      this.numRows = 0;
×
161
      this.nullCount = 0;
×
162
      this.bufferLayout.push(...(props.bufferLayout ?? []));
×
163
      return;
×
164
    }
165

166
    const {metadata, sourceInfo, nullCount = 0} = props;
16✔
167
    const columns = 'columns' in props ? props.columns : props.vectors;
16✔
168
    const normalizedColumns = normalizeGPUColumns(columns);
16✔
169
    const gpuVectors = Object.fromEntries(
16✔
170
      Object.entries(normalizedColumns).filter(
171
        (entry): entry is [string, GPUVector] => entry[1] instanceof GPUVector
27✔
172
      )
173
    );
174
    Object.assign(
16✔
175
      this.gpuConstants,
176
      Object.fromEntries(
177
        Object.entries(normalizedColumns).filter(
178
          (entry): entry is [string, GPUConstant] => entry[1] instanceof GPUConstant
27✔
179
        )
180
      )
181
    );
182
    const hasGPUData = Object.values(gpuVectors).some(vector => vector.data.length > 0);
16✔
183
    const bufferLayout = getGPUVectorBufferLayout(gpuVectors, !hasGPUData);
16✔
184
    const vectorFields = getGPUVectorFields(gpuVectors);
16✔
185
    const fields = getGPUColumnFields(normalizedColumns, vectorFields);
16✔
186
    const batches = createGPURecordBatchesFromVectors(
16✔
187
      gpuVectors,
188
      bufferLayout,
189
      vectorFields,
190
      metadata,
191
      sourceInfo,
192
      nullCount
193
    );
194

195
    this.schema = {fields, metadata: metadata ?? new Map()};
16✔
196
    this.numCols = fields.length;
16✔
197
    const inferredNumRows = batches.reduce((numRows, batch) => numRows + batch.numRows, 0);
16✔
198
    const explicitNumRows = 'columns' in props ? props.numRows : undefined;
16✔
199
    this.numRows = getGPUTableRowCount(inferredNumRows, gpuVectors, explicitNumRows);
16✔
200
    this.nullCount = batches.reduce((totalNullCount, batch) => totalNullCount + batch.nullCount, 0);
16✔
201
    this.bufferLayout.push(...bufferLayout);
16✔
202
    this.batches.push(...batches);
16✔
203
    if (this.batches.length === 0 && Object.keys(this.gpuConstants).length > 0) {
16✔
204
      this.batches.push(
1✔
205
        new GPURecordBatch({gpuData: {}, fields: [], numRows: this.numRows, metadata})
206
      );
207
    }
208
    this.rebuildAggregateVectors();
16✔
209
  }
210

211
  /** Replaces preserved GPU batches with fewer packed batches. */
212
  packBatches(options: GPUTablePackBatchesOptions = {}): this {
6✔
213
    if (this.batches.length <= 1) {
7!
214
      return this;
×
215
    }
216
    if (this.batches.some(batch => batch.gpuData[GPU_TABLE_INDEX_COLUMN_NAME])) {
14✔
217
      throw new Error('GPUTable.packBatches() does not support indexed tables');
1✔
218
    }
219

220
    const batchGroups = createGPUPackGroups(this.batches, options.minBatchSize);
6✔
221
    const nextBatches: GPURecordBatch[] = [];
6✔
222
    const supersededBatches: GPURecordBatch[] = [];
6✔
223

224
    for (const batchGroup of batchGroups) {
6✔
225
      if (batchGroup.length === 1) {
7✔
226
        nextBatches.push(batchGroup[0]);
1✔
227
        continue;
1✔
228
      }
229
      nextBatches.push(
6✔
230
        createPackedGPURecordBatch(batchGroup, this.bufferLayout, getGPUTableVaryingSchema(this))
231
      );
232
      supersededBatches.push(...batchGroup);
5✔
233
    }
234

235
    if (supersededBatches.length === 0) {
5!
236
      return this;
×
237
    }
238

239
    this.batches.splice(0, this.batches.length, ...nextBatches);
5✔
240
    this.refreshFromBatches();
5✔
241
    for (const batch of supersededBatches) {
5✔
242
      batch.destroy();
10✔
243
    }
244
    return this;
5✔
245
  }
246

247
  /** Adds one already-created GPU record batch to this table. */
248
  addBatch(batch: GPURecordBatch): this {
249
    if (this.batches.length === 0) {
10!
NEW
250
      assertMatchingGPURecordBatchSchema(
×
251
        getGPUTableVaryingSchema(this),
252
        batch.schema,
253
        'GPUTable.addBatch()'
254
      );
255
      if (this.bufferLayout.length === 0) {
×
256
        this.bufferLayout.push(...batch.bufferLayout);
×
257
      } else if (!deepEqualBufferLayouts(this.bufferLayout, batch.bufferLayout)) {
×
258
        throw new Error('GPUTable.addBatch() requires matching buffer layouts');
×
259
      }
260
      this.batches.push(batch);
×
261
      return this.refreshFromBatches();
×
262
    }
263
    assertCompatibleGPURecordBatch(this, batch);
10✔
264
    this.batches.push(batch);
10✔
265
    return this.refreshFromBatches();
10✔
266
  }
267

268
  /** Recomputes aggregate row counts and vector views from preserved batches. */
269
  refreshFromBatches(): this {
270
    this.numRows = this.batches.reduce((numRows, batch) => numRows + batch.numRows, 0);
29✔
271
    this.nullCount = this.batches.reduce((nullCount, batch) => nullCount + batch.nullCount, 0);
29✔
272
    if (this.batches.length <= 1 || !this.trySynchronizeAggregateVectors()) {
17✔
273
      this.rebuildAggregateVectors();
7✔
274
    }
275
    return this;
17✔
276
  }
277

278
  /** Keeps only the requested columns and destroys the dropped batch-local data. */
279
  select(...columnNames: string[]): this {
280
    const selectedColumnNames = normalizeSelectedColumnNames(this, columnNames);
2✔
281
    const selectedColumnSet = new Set(selectedColumnNames);
2✔
282

283
    for (const batch of this.batches) {
2✔
284
      const droppedData = Object.entries(batch.gpuData)
2✔
285
        .filter(([name]) => !selectedColumnSet.has(name))
4✔
286
        .map(([, data]) => data);
2✔
287
      for (const data of droppedData) {
2✔
288
        data.destroy();
2✔
289
      }
290
      rebuildGPURecordBatchColumns(batch, selectedColumnNames);
2✔
291
    }
292

293
    rebuildGPUTableColumns(this, selectedColumnNames);
2✔
294
    this.rebuildAggregateVectors();
2✔
295
    return this;
2✔
296
  }
297

298
  /** Removes one column and returns an aggregate GPU vector that owns detached batch data. */
299
  detachVector(columnName: string): GPUVector {
300
    assertGPUTableColumn(this, columnName);
1✔
301
    const detachedData = this.batches.map(batch => batch.gpuData[columnName]);
2✔
302
    const firstData = detachedData[0];
1✔
303
    if (!firstData) {
1!
304
      throw new Error(`GPUTable.detachVector() column "${columnName}" has no GPU data`);
×
305
    }
306
    const detachedVector = new GPUVector({
1✔
307
      type: 'data',
308
      name: columnName,
309
      dataType: firstData.dataType,
310
      format: firstData.format,
311
      data: detachedData,
312
      stride: firstData.stride,
313
      byteStride: firstData.byteStride,
314
      rowByteLength: firstData.rowByteLength,
315
      bufferLayout: getColumnBufferLayout(this.bufferLayout, columnName),
316
      ownsData: true
317
    });
318
    const remainingColumnNames = this.schema.fields
1✔
319
      .map(field => field.name)
2✔
320
      .filter(name => name !== columnName);
2✔
321
    for (const batch of this.batches) {
1✔
322
      rebuildGPURecordBatchColumns(batch, remainingColumnNames);
2✔
323
    }
324
    rebuildGPUTableColumns(this, remainingColumnNames);
1✔
325
    this.rebuildAggregateVectors();
1✔
326
    return detachedVector;
1✔
327
  }
328

329
  /** Removes and returns a half-open range of GPU record batches. */
330
  detachBatches(options: GPUTableDetachBatchesOptions = {}): GPURecordBatch[] {
×
331
    const first = options.first ?? 0;
2!
332
    const last = options.last ?? this.batches.length;
2✔
333
    if (
2!
334
      !Number.isInteger(first) ||
10✔
335
      !Number.isInteger(last) ||
336
      first < 0 ||
337
      last < first ||
338
      last > this.batches.length
339
    ) {
340
      throw new Error('GPUTable.detachBatches() requires a valid half-open batch range');
×
341
    }
342

343
    const detachedBatches = this.batches.splice(first, last - first);
2✔
344
    if (detachedBatches.length === 0) {
2!
345
      return detachedBatches;
×
346
    }
347
    this.refreshFromBatches();
2✔
348
    return detachedBatches;
2✔
349
  }
350

351
  /** Destroys retained GPU batches and follows their vector-level ownership graphs. */
352
  destroy(): void {
353
    for (const batch of this.batches) {
58✔
354
      batch.destroy();
73✔
355
    }
356
  }
357

358
  private rebuildAggregateVectors(): void {
359
    for (const name of Object.keys(this.gpuVectors)) {
91✔
360
      delete this.gpuVectors[name];
14✔
361
    }
362
    const firstBatch = this.batches[0];
91✔
363
    if (firstBatch) {
91!
364
      for (const columnName of Object.keys(firstBatch.gpuData)) {
91✔
365
        const batchData = getBatchColumnData(this.batches, columnName);
140✔
366
        this.gpuVectors[columnName] = createAggregateGPUVector(
140✔
367
          columnName,
368
          batchData,
369
          getColumnBufferLayout(this.bufferLayout, columnName)
370
        );
371
      }
372
    }
373
    synchronizeGPUColumns(this);
91✔
374
  }
375

376
  private trySynchronizeAggregateVectors(): boolean {
377
    const firstBatch = this.batches[0];
12✔
378
    if (!firstBatch) {
12!
379
      return false;
×
380
    }
381

382
    for (const columnName of Object.keys(firstBatch.gpuData)) {
12✔
383
      const aggregateVector = this.gpuVectors[columnName];
27✔
384
      const batchData = getBatchColumnData(this.batches, columnName);
27✔
385
      if (!aggregateVector || !canSynchronizeAggregateVector(aggregateVector, batchData)) {
27✔
386
        return false;
2✔
387
      }
388
    }
389

390
    for (const columnName of Object.keys(firstBatch.gpuData)) {
10✔
391
      const aggregateVector = this.gpuVectors[columnName];
25✔
392
      const batchData = getBatchColumnData(this.batches, columnName);
25✔
393
      for (const data of batchData.slice(aggregateVector.data.length)) {
25✔
394
        aggregateVector.addData(data);
25✔
395
      }
396
    }
397

398
    return true;
10✔
399
  }
400
}
401

402
function canSynchronizeAggregateVector(aggregateVector: GPUVector, batchData: GPUData[]): boolean {
403
  const firstData = batchData[0];
27✔
404
  if (!firstData) {
27!
405
    return false;
×
406
  }
407
  if (
27!
408
    aggregateVector.format !== firstData.format ||
108✔
409
    aggregateVector.stride !== firstData.stride ||
410
    aggregateVector.byteStride !== firstData.byteStride ||
411
    aggregateVector.rowByteLength !== firstData.rowByteLength
412
  ) {
413
    return false;
×
414
  }
415
  if (aggregateVector.data.length > batchData.length) {
27✔
416
    return false;
2✔
417
  }
418
  return aggregateVector.data.every((data, index) => data === batchData[index]);
25✔
419
}
420

421
function getBatchColumnData(batches: GPURecordBatch[], columnName: string): GPUData[] {
422
  return batches.map(batch => {
192✔
423
    const data = batch.gpuData[columnName];
275✔
424
    if (!data) {
275!
425
      throw new Error(`GPUTable batch is missing GPUData "${columnName}"`);
×
426
    }
427
    return data;
275✔
428
  });
429
}
430

431
function createAggregateGPUVector(
432
  columnName: string,
433
  data: GPUData[],
434
  bufferLayout?: BufferLayout
435
): GPUVector {
436
  const firstData = data[0];
140✔
437
  if (!firstData) {
140!
438
    throw new Error(`GPUTable aggregate vector "${columnName}" requires GPUData`);
×
439
  }
440
  return new GPUVector({
140✔
441
    type: 'data',
442
    name: columnName,
443
    dataType: firstData.dataType,
444
    format: firstData.format,
445
    data,
446
    stride: firstData.stride,
447
    byteStride: firstData.byteStride,
448
    rowByteLength: firstData.rowByteLength,
449
    bufferLayout
450
  });
451
}
452

453
function createGPUPackGroups(batches: GPURecordBatch[], minBatchSize?: number): GPURecordBatch[][] {
454
  if (minBatchSize === undefined) {
6✔
455
    return [batches];
5✔
456
  }
457
  if (!Number.isFinite(minBatchSize) || minBatchSize <= 0) {
1!
458
    throw new Error('GPUTable.packBatches() minBatchSize must be a positive number');
×
459
  }
460

461
  const batchGroups: GPURecordBatch[][] = [];
1✔
462
  let currentGroup: GPURecordBatch[] = [];
1✔
463
  let currentRowCount = 0;
1✔
464

465
  for (const batch of batches) {
1✔
466
    currentGroup.push(batch);
3✔
467
    currentRowCount += batch.numRows;
3✔
468
    if (currentRowCount >= minBatchSize) {
3✔
469
      batchGroups.push(currentGroup);
1✔
470
      currentGroup = [];
1✔
471
      currentRowCount = 0;
1✔
472
    }
473
  }
474

475
  if (currentGroup.length > 0) {
1!
476
    batchGroups.push(currentGroup);
1✔
477
  }
478
  return batchGroups;
1✔
479
}
480

481
function createPackedGPURecordBatch(
482
  batchGroup: GPURecordBatch[],
483
  bufferLayout: BufferLayout[],
484
  schema: GPUSchema<any>
485
): GPURecordBatch {
486
  const firstBatch = batchGroup[0];
6✔
487
  const device = getGPURecordBatchDevice(firstBatch);
6✔
488
  const commandEncoder = device.createCommandEncoder();
6✔
489
  const packedData: Record<string, GPUData> = {};
6✔
490

491
  for (const columnName of Object.keys(firstBatch.gpuData)) {
6✔
492
    const sourceData = batchGroup.map(batch => batch.gpuData[columnName]);
20✔
493
    const firstData = sourceData[0];
10✔
494
    if (!firstData) {
10!
495
      throw new Error(`GPUTable batch is missing GPUData "${columnName}"`);
×
496
    }
497
    if (
10✔
498
      sourceData.some(
499
        data =>
500
          data.format &&
19✔
501
          (isValueListGPUVectorFormat(data.format) || isVertexListGPUVectorFormat(data.format))
502
      )
503
    ) {
504
      throw new Error(
1✔
505
        `GPUTable.packBatches() does not support variable-length GPUData "${columnName}"`
506
      );
507
    }
508

509
    const byteLength = sourceData.reduce(
9✔
510
      (totalByteLength, data) => totalByteLength + data.length * data.byteStride,
18✔
511
      0
512
    );
513
    const buffer = device.createBuffer({
9✔
514
      usage: Buffer.VERTEX | Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC,
515
      byteLength
516
    });
517
    let destinationOffset = 0;
9✔
518
    for (const data of sourceData) {
9✔
519
      const copyByteLength = getGPUDataCopyByteLength(data);
18✔
520
      if (copyByteLength === 0) {
18!
521
        continue;
×
522
      }
523
      commandEncoder.copyBufferToBuffer({
18✔
524
        sourceBuffer: getGPUDataConcreteBuffer(data),
525
        sourceOffset: data.byteOffset,
526
        destinationBuffer: buffer,
527
        destinationOffset,
528
        size: copyByteLength
529
      });
530
      destinationOffset += data.length * data.byteStride;
18✔
531
    }
532

533
    packedData[columnName] = new GPUData({
9✔
534
      buffer,
535
      dataType: firstData.dataType,
536
      format: firstData.format,
537
      length: sourceData.reduce((length, data) => length + data.length, 0),
18✔
538
      valueLength: sourceData.reduce((length, data) => length + data.valueLength, 0),
18✔
539
      stride: firstData.stride,
540
      byteStride: firstData.byteStride,
541
      rowByteLength: firstData.rowByteLength,
542
      ownsBuffer: true
543
    });
544
  }
545

546
  device.submit(commandEncoder.finish());
5✔
547
  return new GPURecordBatch({
5✔
548
    gpuData: packedData,
549
    bufferLayout,
550
    fields: schema.fields,
551
    metadata: new Map(schema.metadata),
552
    sourceInfo: getPackedGPURecordBatchSourceInfo(batchGroup),
553
    nullCount: batchGroup.reduce((nullCount, batch) => nullCount + batch.nullCount, 0)
10✔
554
  });
555
}
556

557
function getPackedGPURecordBatchSourceInfo(
558
  batchGroup: GPURecordBatch[]
559
): GPURecordBatchSourceInfo | undefined {
560
  const firstSourceInfo = batchGroup[0]?.sourceInfo;
5✔
561
  if (!firstSourceInfo) {
5!
562
    return undefined;
×
563
  }
564

565
  let sourceRowCount = firstSourceInfo.sourceRowCount;
5✔
566
  let nextSourceRowIndex = firstSourceInfo.sourceRowIndexOffset + firstSourceInfo.sourceRowCount;
5✔
567
  for (const batch of batchGroup.slice(1)) {
5✔
568
    const sourceInfo = batch.sourceInfo;
5✔
569
    if (
5✔
570
      !sourceInfo ||
11✔
571
      sourceInfo.sourceBatchIndex !== firstSourceInfo.sourceBatchIndex ||
572
      sourceInfo.sourceRowIndexOffset !== nextSourceRowIndex
573
    ) {
574
      return undefined;
4✔
575
    }
576
    sourceRowCount += sourceInfo.sourceRowCount;
1✔
577
    nextSourceRowIndex += sourceInfo.sourceRowCount;
1✔
578
  }
579

580
  return {
1✔
581
    sourceBatchIndex: firstSourceInfo.sourceBatchIndex,
582
    sourceRowIndexOffset: firstSourceInfo.sourceRowIndexOffset,
583
    sourceRowCount
584
  };
585
}
586

587
function getGPURecordBatchDevice<T extends GPUTypeMap>(batch: GPURecordBatch<T>) {
588
  const firstData = Object.values(batch.gpuData)[0];
6✔
589
  if (!firstData) {
6!
590
    throw new Error('GPUTable cannot pack an empty GPU record batch');
×
591
  }
592
  return firstData.buffer.device;
6✔
593
}
594

595
function getGPUDataCopyByteLength(data: GPUData): number {
596
  if (data.length === 0) {
18!
597
    return 0;
×
598
  }
599
  return (data.length - 1) * data.byteStride + data.rowByteLength;
18✔
600
}
601

602
function getGPUDataConcreteBuffer(data: GPUData): Buffer {
603
  return data.buffer instanceof DynamicBuffer ? data.buffer.buffer : data.buffer;
18✔
604
}
605

606
function assertCompatibleGPURecordBatch<T extends GPUTypeMap>(
607
  table: GPUTable<T>,
608
  batch: GPURecordBatch<T>
609
): void {
610
  if (!deepEqualBufferLayouts(table.bufferLayout, batch.bufferLayout)) {
10!
611
    throw new Error('GPUTable.addBatch() requires matching buffer layouts');
×
612
  }
613
  assertMatchingGPURecordBatchSchema(
10✔
614
    getGPUTableVaryingSchema(table),
615
    batch.schema,
616
    'GPUTable.addBatch()'
617
  );
618
}
619

620
function assertCompatibleGPURecordBatches<T extends GPUTypeMap>(
621
  batches: GPURecordBatch<T>[]
622
): void {
623
  const firstBatch = batches[0];
65✔
624
  if (!firstBatch) {
65!
625
    return;
×
626
  }
627
  for (const batch of batches.slice(1)) {
65✔
628
    if (!deepEqualBufferLayouts(firstBatch.bufferLayout, batch.bufferLayout)) {
16!
629
      throw new Error('GPUTable batches constructor requires matching buffer layouts');
×
630
    }
631
    assertMatchingGPURecordBatchSchema(
16✔
632
      firstBatch.schema,
633
      batch.schema,
634
      'GPUTable batches constructor'
635
    );
636
  }
637
}
638

639
function assertMatchingGPURecordBatchSchema<T extends GPUTypeMap>(
640
  expectedSchema: GPUSchema<T>,
641
  candidateSchema: GPUSchema<T>,
642
  ownerName: string
643
): void {
644
  if (expectedSchema.fields.length !== candidateSchema.fields.length) {
26!
645
    throw new Error(ownerName + ' requires matching selected schema fields');
×
646
  }
647
  for (let fieldIndex = 0; fieldIndex < expectedSchema.fields.length; fieldIndex++) {
26✔
648
    const tableField = expectedSchema.fields[fieldIndex];
51✔
649
    const batchField = candidateSchema.fields[fieldIndex];
51✔
650
    if (
51!
651
      !batchField ||
153✔
652
      tableField.name !== batchField.name ||
653
      tableField.format !== batchField.format
654
    ) {
655
      throw new Error(ownerName + ' requires matching selected schema fields');
×
656
    }
657
  }
658
}
659

660
function deepEqualBufferLayouts(
661
  expectedBufferLayout: BufferLayout[],
662
  candidateBufferLayout: BufferLayout[]
663
): boolean {
664
  return JSON.stringify(expectedBufferLayout) === JSON.stringify(candidateBufferLayout);
26✔
665
}
666

667
function normalizeSelectedColumnNames<T extends GPUTypeMap>(
668
  table: GPUTable<T>,
669
  columnNames: string[]
670
): string[] {
671
  const knownColumnNames = new Set(table.schema.fields.map(field => field.name));
4✔
672
  const selectedColumnNames = Array.from(new Set(columnNames));
2✔
673
  for (const columnName of selectedColumnNames) {
2✔
674
    if (!knownColumnNames.has(columnName)) {
2!
675
      throw new Error(`GPUTable column "${columnName}" does not exist`);
×
676
    }
677
  }
678
  return selectedColumnNames;
2✔
679
}
680

681
function assertGPUTableColumn<T extends GPUTypeMap>(table: GPUTable<T>, columnName: string): void {
682
  if (!table.schema.fields.some(field => field.name === columnName)) {
2!
683
    throw new Error(`GPUTable column "${columnName}" does not exist`);
×
684
  }
685
}
686

687
function rebuildGPUTableColumns<T extends GPUTypeMap>(
688
  table: GPUTable<T>,
689
  columnNames: string[]
690
): void {
691
  const selectedColumnSet = new Set(columnNames);
3✔
692
  const selectedLayouts = columnNames
3✔
693
    .map(columnName => table.bufferLayout.find(layout => layout.name === columnName))
3✔
694
    .filter((layout): layout is BufferLayout => Boolean(layout));
3✔
695
  const selectedFields = columnNames
3✔
696
    .map(columnName => table.schema.fields.find(field => field.name === columnName))
3✔
697
    .filter((field): field is GPUField => Boolean(field));
3✔
698

699
  table.bufferLayout.splice(0, table.bufferLayout.length, ...selectedLayouts);
3✔
700
  table.schema = {
3✔
701
    fields: selectedFields,
702
    metadata: new Map(table.schema.metadata)
703
  };
704
  table.numCols = selectedFields.length;
3✔
705

706
  for (const name of Object.keys(table.gpuVectors)) {
3✔
707
    if (!selectedColumnSet.has(name)) {
6✔
708
      delete table.gpuVectors[name];
3✔
709
    }
710
  }
711
  for (const name of Object.keys(table.gpuConstants)) {
3✔
NEW
712
    if (!selectedColumnSet.has(name)) {
×
NEW
713
      delete table.gpuConstants[name];
×
714
    }
715
  }
716
  synchronizeGPUColumns(table);
3✔
717
}
718

719
function rebuildGPURecordBatchColumns<T extends GPUTypeMap>(
720
  batch: GPURecordBatch<T>,
721
  columnNames: string[]
722
): void {
723
  const selectedColumnSet = new Set(columnNames);
4✔
724
  const selectedLayouts = columnNames
4✔
725
    .map(columnName => batch.bufferLayout.find(layout => layout.name === columnName))
4✔
726
    .filter((layout): layout is BufferLayout => Boolean(layout));
4✔
727
  const selectedFields = columnNames
4✔
728
    .map(columnName => batch.schema.fields.find(field => field.name === columnName))
4✔
729
    .filter((field): field is GPUField => Boolean(field));
4✔
730

731
  for (const name of Object.keys(batch.gpuData)) {
4✔
732
    if (!selectedColumnSet.has(name)) {
8✔
733
      delete batch.gpuData[name];
4✔
734
    }
735
  }
736
  batch.bufferLayout.splice(0, batch.bufferLayout.length, ...selectedLayouts);
4✔
737
  batch.schema = {
4✔
738
    fields: selectedFields,
739
    metadata: new Map(batch.schema.metadata)
740
  };
741
  batch.numCols = selectedFields.length;
4✔
742
}
743

744
function normalizeGPUColumns(
745
  columns: Record<string, GPUVector | GPUConstant> | GPUVector[]
746
): Record<string, GPUVector | GPUConstant> {
747
  const normalizedColumns = Array.isArray(columns)
16✔
748
    ? Object.fromEntries(columns.map(vector => [vector.name, vector]))
1✔
749
    : columns;
750
  for (const [columnName, column] of Object.entries(normalizedColumns)) {
16✔
751
    if (column instanceof GPUVector && column.name !== columnName) {
27!
NEW
752
      throw new Error(
×
753
        `GPUTable column name "${columnName}" does not match GPUVector.name "${column.name}"`
754
      );
755
    }
756
    if (isGPUTableIndexColumnName(columnName) && column instanceof GPUConstant) {
27!
NEW
757
      throw new Error('GPUTable reserved index column "indices" cannot be constant');
×
758
    }
759
  }
760
  return normalizedColumns;
16✔
761
}
762

763
function getGPUColumnFields(
764
  columns: Record<string, GPUVector | GPUConstant>,
765
  vectorFields: GPUField[]
766
): GPUField[] {
767
  const vectorFieldMap = new Map(vectorFields.map(field => [field.name, field]));
81✔
768
  return Object.entries(columns).map(([columnName, column]) => {
81✔
769
    if (column instanceof GPUConstant) {
27✔
770
      return {
6✔
771
        name: columnName,
772
        format: column.format,
773
        nullable: false,
774
        metadata: new Map()
775
      };
776
    }
777
    const field = vectorFieldMap.get(columnName);
21✔
778
    if (!field) {
21!
NEW
779
      throw new Error(`GPUTable cannot synthesize field "${columnName}"`);
×
780
    }
781
    return field;
21✔
782
  });
783
}
784

785
function getGPUConstantFields(constants: Record<string, GPUConstant>): GPUField[] {
786
  return getGPUColumnFields(constants, []);
65✔
787
}
788

789
function getGPUTableRowCount(
790
  inferredNumRows: number,
791
  gpuVectors: Record<string, GPUVector>,
792
  explicitNumRows?: number
793
): number {
794
  if (
16!
795
    explicitNumRows !== undefined &&
18✔
796
    (!Number.isInteger(explicitNumRows) || explicitNumRows < 0)
797
  ) {
NEW
798
    throw new Error('GPUTable columns constructor requires numRows to be a non-negative integer');
×
799
  }
800
  if (Object.keys(gpuVectors).length === 0) {
16✔
801
    if (explicitNumRows === undefined) {
1!
NEW
802
      throw new Error('GPUTable columns constructor requires numRows for an all-constant table');
×
803
    }
804
    return explicitNumRows;
1✔
805
  }
806
  if (explicitNumRows !== undefined && explicitNumRows !== inferredNumRows) {
15!
NEW
807
    throw new Error('GPUTable columns constructor numRows must match varying columns');
×
808
  }
809
  return inferredNumRows;
15✔
810
}
811

812
function synchronizeGPUColumns<T extends GPUTypeMap>(table: GPUTable<T>): void {
813
  for (const name of Object.keys(table.gpuColumns)) {
94✔
814
    delete table.gpuColumns[name];
20✔
815
  }
816
  Object.assign(table.gpuColumns, table.gpuVectors, table.gpuConstants);
94✔
817
}
818

819
function getGPUTableVaryingSchema<T extends GPUTypeMap>(table: GPUTable<T>): GPUSchema<T> {
820
  return {
16✔
821
    fields: table.schema.fields.filter(field => !table.gpuConstants[field.name]),
35✔
822
    metadata: new Map(table.schema.metadata)
823
  };
824
}
825

826
function getGPUVectorBufferLayout(
827
  gpuVectors: Record<string, GPUVector>,
828
  allowVariableLengthWithoutLayout = false
×
829
): BufferLayout[] {
830
  return Object.values(gpuVectors).flatMap(vector =>
16✔
831
    isGPUTableIndexColumnName(vector.name)
21✔
832
      ? []
833
      : synthesizeGPUVectorBufferLayout(vector, allowVariableLengthWithoutLayout)
834
  );
835
}
836

837
function synthesizeGPUVectorBufferLayout(
838
  vector: GPUVector,
839
  allowVariableLengthWithoutLayout: boolean
840
): BufferLayout[] {
841
  if (vector.bufferLayout) {
18✔
842
    return [vector.bufferLayout];
2✔
843
  }
844
  if (!vector.format) {
16!
845
    throw new Error(
×
846
      'GPUTable cannot synthesize a buffer layout for vector "' + vector.name + '" without a format'
847
    );
848
  }
849
  if (isVertexListGPUVectorFormat(vector.format)) {
16!
850
    if (allowVariableLengthWithoutLayout) {
×
851
      return [];
×
852
    }
853
    throw new Error(
×
854
      'GPUTable cannot synthesize a generic buffer layout for vertex-list vector "' +
855
        vector.name +
856
        '"'
857
    );
858
  }
859
  if (isValueListGPUVectorFormat(vector.format)) {
16!
860
    if (allowVariableLengthWithoutLayout) {
×
861
      return [];
×
862
    }
863
    throw new Error(
×
864
      'GPUTable cannot synthesize a generic buffer layout for value-list vector "' +
865
        vector.name +
866
        '"'
867
    );
868
  }
869
  return [
16✔
870
    {
871
      name: vector.name,
872
      byteStride: vector.byteStride,
873
      format: getGPUVectorElementFormat(vector.format) as VertexFormat
874
    }
875
  ];
876
}
877

878
function getGPUVectorFields(gpuVectors: Record<string, GPUVector>): GPUField[] {
879
  return Object.values(gpuVectors).map(vector => {
16✔
880
    if (!vector.format) {
21✔
881
      if (vector.bufferLayout) {
2!
882
        return {name: vector.name, nullable: false, metadata: new Map()};
2✔
883
      }
884
      throw new Error(
×
885
        'GPUTable cannot synthesize a schema field for vector "' +
886
          vector.name +
887
          '" without a format'
888
      );
889
    }
890
    return {
19✔
891
      name: vector.name,
892
      format: vector.format,
893
      nullable: false,
894
      metadata: new Map()
895
    };
896
  });
897
}
898

899
function createGPURecordBatchesFromVectors(
900
  gpuVectors: Record<string, GPUVector>,
901
  bufferLayout: BufferLayout[],
902
  fields: GPUField[],
903
  metadata: Map<string, string> | undefined,
904
  sourceInfo: GPURecordBatchSourceInfo | undefined,
905
  nullCount: number
906
): GPURecordBatch[] {
907
  const vectors = Object.values(gpuVectors);
16✔
908
  const batchCount = vectors[0]?.data.length ?? 0;
16✔
909
  for (const vector of vectors) {
16✔
910
    validateGPUVectorChunks(vector);
21✔
911
    if (vector.data.length !== batchCount) {
21!
912
      throw new Error('GPUTable vectors constructor requires matching GPUData chunk counts');
×
913
    }
914
  }
915
  if (batchCount !== 1 && (sourceInfo || nullCount > 0)) {
16!
916
    throw new Error(
×
917
      'GPUTable vectors constructor supports sourceInfo and nullCount only with one GPUData chunk'
918
    );
919
  }
920

921
  const batches: GPURecordBatch[] = [];
16✔
922
  for (let batchIndex = 0; batchIndex < batchCount; batchIndex++) {
16✔
923
    const gpuData: Record<string, GPUData> = {};
15✔
924
    let numRows: number | undefined;
925
    for (const [columnName, vector] of Object.entries(gpuVectors)) {
15✔
926
      const data = vector.data[batchIndex];
21✔
927
      if (!data) {
21!
928
        throw new Error(
×
929
          'GPUTable vectors constructor is missing GPUData chunk for "' + columnName + '"'
930
        );
931
      }
932
      if (numRows === undefined) {
21✔
933
        numRows = data.length;
15✔
934
      } else if (data.length !== numRows) {
6!
935
        throw new Error(
×
936
          'GPUTable vectors constructor requires matching row counts in batch ' + batchIndex
937
        );
938
      }
939
      gpuData[columnName] = data;
21✔
940
    }
941
    batches.push(
15✔
942
      new GPURecordBatch({
943
        gpuData,
944
        bufferLayout,
945
        fields,
946
        metadata,
947
        ...(sourceInfo ? {sourceInfo} : {}),
15✔
948
        nullCount
949
      })
950
    );
951
  }
952
  return batches;
16✔
953
}
954

955
function validateGPUVectorChunks(vector: GPUVector): void {
956
  for (const data of vector.data) {
21✔
957
    if (
21!
958
      data.format !== vector.format ||
84✔
959
      data.stride !== vector.stride ||
960
      data.byteStride !== vector.byteStride ||
961
      data.rowByteLength !== vector.rowByteLength
962
    ) {
963
      throw new Error(
×
964
        'GPUTable vectors constructor requires compatible GPUData chunks for "' + vector.name + '"'
965
      );
966
    }
967
  }
968
}
969

970
function getColumnBufferLayout(
971
  bufferLayout: BufferLayout[],
972
  columnName: string
973
): BufferLayout | undefined {
974
  const layout = bufferLayout.find(candidateLayout => candidateLayout.name === columnName);
186✔
975
  return layout?.attributes ? layout : undefined;
141✔
976
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc