From 5e1c686c7f93fe96ff784dfe591fe391b2a31e8f Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Mon, 20 May 2024 14:59:22 -0700 Subject: [PATCH] fix: refactor runtime and runtime-async packages to allocate/grow buffers on demand (#484) Fixes #471 --- .../docs/functions/exposeModelWorker.md | 6 +- packages/runtime-async/src/runner.ts | 58 +-- packages/runtime-async/src/worker.ts | 151 +++----- packages/runtime-async/tests/runner.spec.ts | 78 +++- packages/runtime/docs/classes/WasmBuffer.md | 11 +- packages/runtime/docs/classes/WasmModel.md | 66 +++- .../runtime/docs/interfaces/ModelRunner.md | 2 +- .../docs/interfaces/WasmModelInitResult.md | 14 +- packages/runtime/src/_shared/index.ts | 5 +- .../{model-runner => _shared}/inputs.spec.ts | 0 .../src/{model-runner => _shared}/inputs.ts | 2 +- .../{model-runner => _shared}/outputs.spec.ts | 0 .../src/{model-runner => _shared}/outputs.ts | 8 +- packages/runtime/src/_shared/types.ts | 18 +- packages/runtime/src/_shared/var-indices.ts | 53 +++ packages/runtime/src/index.ts | 3 +- packages/runtime/src/model-runner/index.ts | 6 +- .../src/model-runner/model-listing.spec.ts | 2 +- .../runtime/src/model-runner/model-listing.ts | 10 +- .../src/model-runner/model-runner.spec.ts | 86 ----- .../runtime/src/model-runner/model-runner.ts | 87 +---- .../synchronous-model-runner.spec.ts | 199 +++++++++++ .../model-runner/synchronous-model-runner.ts | 69 ++++ .../model-scheduler/model-scheduler.spec.ts | 16 +- .../src/model-scheduler/model-scheduler.ts | 4 +- packages/runtime/src/perf/index.ts | 4 + .../src/{model-runner => perf}/perf.ts | 0 .../src/runnable-model/base-runnable-model.ts | 104 ++++++ .../buffered-run-model-params.spec.ts | 251 +++++++++++++ .../buffered-run-model-params.ts | 336 ++++++++++++++++++ packages/runtime/src/runnable-model/index.ts | 7 + .../referenced-run-model-params.spec.ts | 165 +++++++++ .../referenced-run-model-params.ts | 139 ++++++++ .../src/runnable-model/run-model-params.ts | 88 +++++ .../src/runnable-model/runnable-model.ts | 43 +++ packages/runtime/src/wasm-model/index.ts | 2 +- .../runtime/src/wasm-model/wasm-buffer.ts | 15 +- packages/runtime/src/wasm-model/wasm-model.ts | 191 +++++----- 38 files changed, 1812 insertions(+), 487 deletions(-) rename packages/runtime/src/{model-runner => _shared}/inputs.spec.ts (100%) rename packages/runtime/src/{model-runner => _shared}/inputs.ts (97%) rename packages/runtime/src/{model-runner => _shared}/outputs.spec.ts (100%) rename packages/runtime/src/{model-runner => _shared}/outputs.ts (97%) create mode 100644 packages/runtime/src/_shared/var-indices.ts delete mode 100644 packages/runtime/src/model-runner/model-runner.spec.ts create mode 100644 packages/runtime/src/model-runner/synchronous-model-runner.spec.ts create mode 100644 packages/runtime/src/model-runner/synchronous-model-runner.ts create mode 100644 packages/runtime/src/perf/index.ts rename packages/runtime/src/{model-runner => perf}/perf.ts (100%) create mode 100644 packages/runtime/src/runnable-model/base-runnable-model.ts create mode 100644 packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts create mode 100644 packages/runtime/src/runnable-model/buffered-run-model-params.ts create mode 100644 packages/runtime/src/runnable-model/index.ts create mode 100644 packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts create mode 100644 packages/runtime/src/runnable-model/referenced-run-model-params.ts create mode 100644 packages/runtime/src/runnable-model/run-model-params.ts create mode 100644 packages/runtime/src/runnable-model/runnable-model.ts diff --git a/packages/runtime-async/docs/functions/exposeModelWorker.md b/packages/runtime-async/docs/functions/exposeModelWorker.md index 81017bed..607008df 100644 --- a/packages/runtime-async/docs/functions/exposeModelWorker.md +++ b/packages/runtime-async/docs/functions/exposeModelWorker.md @@ -6,14 +6,14 @@ Expose an object in the current worker thread that communicates with the [`ModelRunner`](../../../runtime/docs/interfaces/ModelRunner.md) instance running in the main thread. The exposed worker -object will take care of running the `WasmModel` on the worker thread -and sending the outputs back to the main process. +object will take care of running the `RunnableModel` on the worker thread +and sending the outputs back to the main thread. #### Parameters | Name | Type | Description | | :------ | :------ | :------ | -| `init` | () => `Promise`<[`WasmModelInitResult`](../../../runtime/docs/interfaces/WasmModelInitResult.md)\> | The function that initializes the `WasmModel` instance that is used in the worker thread. | +| `init` | () => `Promise`<`RunnableModel` \| [`WasmModelInitResult`](../../../runtime/docs/interfaces/WasmModelInitResult.md)\> | The function that initializes the `RunnableModel` instance that is used in the worker thread. | #### Returns diff --git a/packages/runtime-async/src/runner.ts b/packages/runtime-async/src/runner.ts index dd458511..9eeb63db 100644 --- a/packages/runtime-async/src/runner.ts +++ b/packages/runtime-async/src/runner.ts @@ -3,7 +3,7 @@ import { BlobWorker, spawn, Thread, Transfer, Worker } from 'threads' import type { ModelRunner } from '@sdeverywhere/runtime' -import { Outputs, updateOutputIndices } from '@sdeverywhere/runtime' +import { BufferedRunModelParams, Outputs } from '@sdeverywhere/runtime' /** * Initialize a `ModelRunner` that runs the model asynchronously in a worker thread. @@ -57,29 +57,9 @@ async function spawnAsyncModelRunnerWithWorker(worker: Worker): Promise 0) { - const outputSpecs = outputs.varSpecs || [] - const indicesArray = new Int32Array(ioBuffer, indicesOffsetInBytes, indicesLengthInElements) - updateOutputIndices(indicesArray, outputSpecs) - } + // Update the I/O parameters + params.updateFromParams(inputs, outputs) // Run the model in the worker. We pass the underlying `ArrayBuffer` // instance back to the worker wrapped in a `Transfer` to make it // no-copy transferable, and then the worker will return it back // to us. + let ioBuffer: ArrayBuffer try { - ioBuffer = await modelWorker.runModel(Transfer(ioBuffer)) + ioBuffer = await modelWorker.runModel(Transfer(params.getEncodedBuffer())) } finally { running = false } - // Save the model run time - const runTimeArray = new Float64Array(ioBuffer, runTimeOffsetInBytes, runTimeLengthInElements) - outputs.runTimeInMillis = runTimeArray[0] + // Once the buffer is transferred to the worker, the buffer in the + // `BufferedRunModelParams` becomes "detached" and is no longer usable. + // After the buffer is transferred back from the worker, we need to + // restore the state of the object to use the new buffer. + params.updateFromEncodedBuffer(ioBuffer) - // Capture the outputs array by copying the data into the given `Outputs` - // data structure - const outputsArray = new Float64Array(ioBuffer, outputsOffsetInBytes, outputsLengthInElements) - outputs.updateFromBuffer(outputsArray, outputRowLength) + // Copy the output values and elapsed time from the buffer to the + // `Outputs` instance + params.finalizeOutputs(outputs) return outputs }, diff --git a/packages/runtime-async/src/worker.ts b/packages/runtime-async/src/worker.ts index ddd548e6..fddffaa3 100644 --- a/packages/runtime-async/src/worker.ts +++ b/packages/runtime-async/src/worker.ts @@ -3,122 +3,79 @@ import type { TransferDescriptor } from 'threads' import { expose, Transfer } from 'threads/worker' -import type { WasmBuffer, WasmModel, WasmModelInitResult } from '@sdeverywhere/runtime' -import { perfElapsed, perfNow } from '@sdeverywhere/runtime' +import type { RunnableModel, WasmModelInitResult } from '@sdeverywhere/runtime' +import { BufferedRunModelParams } from '@sdeverywhere/runtime' +// TODO: To avoid breaking existing code that returns `WasmModelInitResult` +// from this init function, we allow it to return either `WasmModelInitResult` +// or the newer `RunnableModel`. We will remove the `WasmModelInitResult` part +// in a future set of changes. /** @hidden */ -let initWasmModel: () => Promise -/** @hidden */ -let wasmModel: WasmModel -/** @hidden */ -let inputsWasmBuffer: WasmBuffer -/** @hidden */ -let outputsWasmBuffer: WasmBuffer +let initRunnableModel: () => Promise + /** @hidden */ -let outputIndicesWasmBuffer: WasmBuffer +let runnableModel: RunnableModel + +/** + * Maintain a `BufferedRunModelParams` instance that wraps the transferable buffer + * containing the I/O parameters. + * @hidden + */ +const params = new BufferedRunModelParams() interface InitResult { outputVarIds: string[] startTime: number endTime: number saveFreq: number - inputsLength: number - outputsLength: number - outputIndicesLength: number outputRowLength: number - ioBuffer: ArrayBuffer } /** @hidden */ const modelWorker = { - async initModel(): Promise> { - if (wasmModel) { - throw new Error('WasmModel was already initialized') + async initModel(): Promise { + if (runnableModel) { + throw new Error('RunnableModel was already initialized') } - // Initialize the wasm model and associated buffers - const wasmResult = await initWasmModel() - - // Capture the `WasmModel` instance and `WasmBuffer` instances - wasmModel = wasmResult.model - inputsWasmBuffer = wasmResult.inputsBuffer - outputsWasmBuffer = wasmResult.outputsBuffer - outputIndicesWasmBuffer = wasmResult.outputIndicesBuffer - - // Create a combined array that will hold a copy of the inputs and outputs - // wasm buffers; this buffer is no-copy transferable, whereas the wasm ones - // are not allowed to be transferred - const runTimeLength = 8 - const inputsLength = inputsWasmBuffer.getArrayView().length - const outputsLength = outputsWasmBuffer.getArrayView().length - const outputIndicesLength = outputIndicesWasmBuffer?.getArrayView().length || 0 - const totalLength = runTimeLength + inputsLength + outputsLength + outputIndicesLength - const ioArray = new Float64Array(totalLength) + // Initialize the runnable model + // TODO: To avoid breaking existing code that returns `WasmModelInitResult` + // from this init function, we allow it to return either `WasmModelInitResult` + // or the newer `RunnableModel`. We will remove the `WasmModelInitResult` part + // in a future set of changes. + const initResult = await initRunnableModel() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((initResult as any).model !== undefined) { + // The result is a `WasmModelInitResult`, so extract the `WasmModel` (which implements + // the `RunnableModel` interface) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runnableModel = (initResult as any).model as RunnableModel + } else { + // Otherwise, we assume the result is a `RunnableModel` + runnableModel = initResult as RunnableModel + } - // Transfer the underlying buffer to the runner - const ioBuffer = ioArray.buffer - const initResult: InitResult = { - outputVarIds: wasmResult.outputVarIds, - startTime: wasmModel.startTime, - endTime: wasmModel.endTime, - saveFreq: wasmModel.saveFreq, - inputsLength, - outputsLength, - outputIndicesLength, - outputRowLength: wasmModel.numSavePoints, - ioBuffer + // Transfer the model metadata to the runner + return { + outputVarIds: runnableModel.outputVarIds, + startTime: runnableModel.startTime, + endTime: runnableModel.endTime, + saveFreq: runnableModel.saveFreq, + outputRowLength: runnableModel.numSavePoints } - return Transfer(initResult, [ioBuffer]) }, runModel(ioBuffer: ArrayBuffer): TransferDescriptor { - if (!wasmModel) { - throw new Error('WasmModel must be initialized before running the model in worker') + if (!runnableModel) { + throw new Error('RunnableModel must be initialized before running the model in worker') } - // The run time is stored in the first 8 bytes - const runTimeOffsetInBytes = 0 - const runTimeLengthInElements = 1 - const runTimeLengthInBytes = runTimeLengthInElements * 8 - - // Copy the inputs into the wasm inputs buffer - const inputsWasmArray = inputsWasmBuffer.getArrayView() - const inputsOffsetInBytes = runTimeOffsetInBytes + runTimeLengthInBytes - const inputsLengthInElements = inputsWasmArray.length - const inputsLengthInBytes = inputsWasmArray.byteLength - const inputsBufferArray = new Float64Array(ioBuffer, inputsOffsetInBytes, inputsLengthInElements) - inputsWasmArray.set(inputsBufferArray) - - // Copy the output indices into the wasm buffer, if needed - const outputsWasmArray = outputsWasmBuffer.getArrayView() - const outputsOffsetInBytes = runTimeLengthInBytes + inputsLengthInBytes - const outputsLengthInBytes = outputsWasmArray.byteLength - let useIndices = false - if (outputIndicesWasmBuffer) { - const indicesWasmArray = outputIndicesWasmBuffer.getArrayView() - const indicesLengthInElements = indicesWasmArray.length - const indicesOffsetInBytes = outputsOffsetInBytes + outputsLengthInBytes - const indicesBufferArray = new Int32Array(ioBuffer, indicesOffsetInBytes, indicesLengthInElements) - if (indicesBufferArray[0] !== 0) { - // Only use the indices if the first index is non-zero - indicesWasmArray.set(indicesBufferArray) - useIndices = true - } - } - - // Run the model using the wasm buffers - const t0 = perfNow() - wasmModel.runModel(inputsWasmBuffer, outputsWasmBuffer, useIndices ? outputIndicesWasmBuffer : undefined) - const elapsed = perfElapsed(t0) - - // Write the model run time to the buffer - const runTimeBufferArray = new Float64Array(ioBuffer, runTimeOffsetInBytes, runTimeLengthInElements) - runTimeBufferArray[0] = elapsed + // Update the `BufferedRunModelParams` to use the values in the buffer that was transferred + // from the runner to the worker + params.updateFromEncodedBuffer(ioBuffer) - // Copy the outputs from the wasm outputs buffer - const outputsLengthInElements = outputsWasmArray.length - const outputsBufferArray = new Float64Array(ioBuffer, outputsOffsetInBytes, outputsLengthInElements) - outputsBufferArray.set(outputsWasmArray) + // Run the model synchronously on the worker thread using those I/O parameters + runnableModel.runModel(params) // Transfer the buffer back to the runner return Transfer(ioBuffer) @@ -128,16 +85,16 @@ const modelWorker = { /** * Expose an object in the current worker thread that communicates with the * `ModelRunner` instance running in the main thread. The exposed worker - * object will take care of running the `WasmModel` on the worker thread - * and sending the outputs back to the main process. + * object will take care of running the `RunnableModel` on the worker thread + * and sending the outputs back to the main thread. * - * @param init The function that initializes the `WasmModel` instance that + * @param init The function that initializes the `RunnableModel` instance that * is used in the worker thread. */ -export function exposeModelWorker(init: () => Promise): void { +export function exposeModelWorker(init: () => Promise): void { // Save the initializer, which will be used when the runner calls `initModel` // on the worker - initWasmModel = init + initRunnableModel = init // Expose the worker implementation to `threads.js` expose(modelWorker) diff --git a/packages/runtime-async/tests/runner.spec.ts b/packages/runtime-async/tests/runner.spec.ts index 53089a2d..baafe9f6 100644 --- a/packages/runtime-async/tests/runner.spec.ts +++ b/packages/runtime-async/tests/runner.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { ModelRunner } from '@sdeverywhere/runtime' -import { createInputValue } from '@sdeverywhere/runtime' +import { ModelListing, createInputValue } from '@sdeverywhere/runtime' import { spawnAsyncModelRunner } from '../src/runner' @@ -30,22 +30,28 @@ function initWasmModel() { cwrap: (fname) => { // Return a mock implementation of each wrapped C function switch (fname) { - case 'getMaxOutputIndices': - return () => 1000 case 'getInitialTime': return () => 2000 case 'getFinalTime': - return () => 2100 + return () => 2002 case 'getSaveper': return () => 1 case 'runModelWithBuffers': - return (inputsAddress, outputsAddress) => { - // The outputsAddress is in bytes, so convert to float64 offset + return (inputsAddress, outputsAddress, outputIndicesAddress) => { + // These address values are in bytes, so convert to float64 offset + const inputsOffset = inputsAddress / 8 const outputsOffset = outputsAddress / 8 - // Store a value in 2000 for the first output series - heapF64.set([6], outputsOffset) - // Store a value in 2100 for the second output series - heapF64.set([7], outputsOffset + 201) + + // This address is in bytes too, so convert to int32 offset + const outputIndicesOffset = outputIndicesAddress / 4 + + if (outputIndicesAddress === 0) { + // Store 3 values for the _output_1, and 3 for _output_2 + heapF64.set([1, 2, 3, 4, 5, 6], outputsOffset) + } else { + // Store 3 values for each of the three variables + heapF64.set([7, 8, 9, 4, 5, 6, 1, 2, 3], outputsOffset) + } } default: throw new Error(\`Unhandled call to cwrap with function name '\${fname}'\`) @@ -65,6 +71,13 @@ function initWasmModel() { exposeModelWorker(initWasmModel) ` +const p = (x: number, y: number) => { + return { + x, + y + } +} + describe('spawnAsyncModelRunner', () => { let runner: ModelRunner @@ -78,15 +91,52 @@ describe('spawnAsyncModelRunner', () => { } }) - it('should run the model in a worker', async () => { + it('should run the model in a worker (simple case with inputs and outputs only)', async () => { expect(runner).toBeDefined() - const inputs = [createInputValue('_input_1', 0), createInputValue('_input_2', 0), createInputValue('_input_3', 0)] + const inputs = [createInputValue('_input_1', 7), createInputValue('_input_2', 8), createInputValue('_input_3', 9)] const inOutputs = runner.createOutputs() const outOutputs = await runner.runModel(inputs, inOutputs) expect(outOutputs).toBeDefined() expect(outOutputs.runTimeInMillis).toBeGreaterThan(0) - expect(outOutputs.getSeriesForVar('_output_1')?.getValueAtTime(2000)).toBe(6) - expect(outOutputs.getSeriesForVar('_output_2')?.getValueAtTime(2100)).toBe(7) + expect(outOutputs.getSeriesForVar('_output_1')!.points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outOutputs.getSeriesForVar('_output_2')!.points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + }) + + it('should run the model in a worker (when output var specs are included)', async () => { + const json = ` +{ + "dimensions": [ + ], + "variables": [ + { + "refId": "_output_1", + "varName": "_output_1", + "varIndex": 1 + }, + { + "refId": "_output_2", + "varName": "_output_2", + "varIndex": 2 + }, + { + "refId": "_x", + "varName": "_x", + "varIndex": 3 + } + ] +} +` + + const listing = new ModelListing(json) + const inputs = [7, 8, 9] + const normalOutputs = runner.createOutputs() + const implOutputs = listing.deriveOutputs(normalOutputs, ['_x', '_output_2', '_output_1']) + const outOutputs = await runner.runModel(inputs, implOutputs) + expect(outOutputs).toBeDefined() + expect(outOutputs.runTimeInMillis).toBeGreaterThan(0) + expect(outOutputs.getSeriesForVar('_x')!.points).toEqual([p(2000, 7), p(2001, 8), p(2002, 9)]) + expect(outOutputs.getSeriesForVar('_output_2')!.points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + expect(outOutputs.getSeriesForVar('_output_1')!.points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) }) it('should throw an error if runModel is called after the runner has been terminated', async () => { diff --git a/packages/runtime/docs/classes/WasmBuffer.md b/packages/runtime/docs/classes/WasmBuffer.md index 0686e3d3..a8e95055 100644 --- a/packages/runtime/docs/classes/WasmBuffer.md +++ b/packages/runtime/docs/classes/WasmBuffer.md @@ -23,7 +23,7 @@ out of the wasm buffer. ### constructor -**new WasmBuffer**<`ArrType`\>(`wasmModule`, `byteOffset`, `heapArray`) +**new WasmBuffer**<`ArrType`\>(`wasmModule`, `numElements`, `byteOffset`, `heapArray`) #### Type parameters @@ -36,9 +36,18 @@ out of the wasm buffer. | Name | Type | Description | | :------ | :------ | :------ | | `wasmModule` | [`WasmModule`](../interfaces/WasmModule.md) | The `WasmModule` used to initialize the memory. | +| `numElements` | `number` | The number of elements in the buffer. | | `byteOffset` | `number` | The byte offset within the wasm heap. | | `heapArray` | `ArrType` | The array view on the underlying heap buffer. | +## Properties + +### numElements + + **numElements**: `number` + +The number of elements in the buffer. + ## Methods ### getArrayView diff --git a/packages/runtime/docs/classes/WasmModel.md b/packages/runtime/docs/classes/WasmModel.md index 3bb07cbd..901043c6 100644 --- a/packages/runtime/docs/classes/WasmModel.md +++ b/packages/runtime/docs/classes/WasmModel.md @@ -5,17 +5,22 @@ An interface to the generated WebAssembly model. Allows for running the model with a given set of input values, producing a set of output values. +## Implements + +- `RunnableModel` + ## Constructors ### constructor -**new WasmModel**(`wasmModule`) +**new WasmModel**(`wasmModule`, `outputVarIds`) #### Parameters | Name | Type | Description | | :------ | :------ | :------ | | `wasmModule` | [`WasmModule`](../interfaces/WasmModule.md) | The `WasmModule` that provides access to the native functions. | +| `outputVarIds` | `string`[] | The output variable IDs for this model. | ## Properties @@ -25,6 +30,10 @@ a given set of input values, producing a set of output values. The start time for the model (aka `INITIAL TIME`). +#### Implementation of + +RunnableModel.startTime + ___ ### endTime @@ -33,6 +42,10 @@ ___ The end time for the model (aka `FINAL TIME`). +#### Implementation of + +RunnableModel.endTime + ___ ### saveFreq @@ -41,6 +54,10 @@ ___ The frequency with which output values are saved (aka `SAVEPER`). +#### Implementation of + +RunnableModel.saveFreq + ___ ### numSavePoints @@ -49,23 +66,52 @@ ___ The number of save points for each output. +#### Implementation of + +RunnableModel.numSavePoints + +___ + +### outputVarIds + + `Readonly` **outputVarIds**: `string`[] + +The output variable IDs for this model. + +#### Implementation of + +RunnableModel.outputVarIds + ## Methods ### runModel -**runModel**(`inputs`, `outputs`, `outputIndices?`): `void` - -Run the model, using inputs from the `inputs` buffer, and writing outputs into -the `outputs` buffer. +**runModel**(`params`): `void` #### Parameters -| Name | Type | Description | -| :------ | :------ | :------ | -| `inputs` | [`WasmBuffer`](WasmBuffer.md)<`Float64Array`\> | The buffer containing inputs in the order expected by the model. | -| `outputs` | [`WasmBuffer`](WasmBuffer.md)<`Float64Array`\> | The buffer into which the model will store output values. | -| `outputIndices?` | [`WasmBuffer`](WasmBuffer.md)<`Int32Array`\> | The buffer used to control which variables are written to `outputs`. | +| Name | Type | +| :------ | :------ | +| `params` | `RunModelParams` | + +#### Returns + +`void` + +#### Implementation of + +RunnableModel.runModel + +___ + +### terminate + +**terminate**(): `void` #### Returns `void` + +#### Implementation of + +RunnableModel.terminate diff --git a/packages/runtime/docs/interfaces/ModelRunner.md b/packages/runtime/docs/interfaces/ModelRunner.md index 06e5c668..8baa379f 100644 --- a/packages/runtime/docs/interfaces/ModelRunner.md +++ b/packages/runtime/docs/interfaces/ModelRunner.md @@ -32,7 +32,7 @@ Run the model. | Name | Type | Description | | :------ | :------ | :------ | -| `inputs` | [`InputValue`](InputValue.md)[] | The model input values (must be in the same order as in the spec file). | +| `inputs` | (`number` \| [`InputValue`](InputValue.md))[] | The model input values (must be in the same order as in the spec file). | | `outputs` | [`Outputs`](../classes/Outputs.md) | The structure into which the model outputs will be stored. | #### Returns diff --git a/packages/runtime/docs/interfaces/WasmModelInitResult.md b/packages/runtime/docs/interfaces/WasmModelInitResult.md index 11e1061f..7de9c674 100644 --- a/packages/runtime/docs/interfaces/WasmModelInitResult.md +++ b/packages/runtime/docs/interfaces/WasmModelInitResult.md @@ -14,19 +14,11 @@ The wasm model. ___ -### inputsBuffer +### numInputs - **inputsBuffer**: [`WasmBuffer`](../classes/WasmBuffer.md)<`Float64Array`\> + **numInputs**: `number` -The buffer used to pass input values to the model. - -___ - -### outputsBuffer - - **outputsBuffer**: [`WasmBuffer`](../classes/WasmBuffer.md)<`Float64Array`\> - -The buffer used to receive output values from the model. +The number of input variables. ___ diff --git a/packages/runtime/src/_shared/index.ts b/packages/runtime/src/_shared/index.ts index 2637b276..289aaa63 100644 --- a/packages/runtime/src/_shared/index.ts +++ b/packages/runtime/src/_shared/index.ts @@ -1,3 +1,6 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -export type { InputVarId, OutputVarId, OutputVarSpec } from './types' +export * from './types' +export * from './inputs' +export * from './outputs' +export * from './var-indices' diff --git a/packages/runtime/src/model-runner/inputs.spec.ts b/packages/runtime/src/_shared/inputs.spec.ts similarity index 100% rename from packages/runtime/src/model-runner/inputs.spec.ts rename to packages/runtime/src/_shared/inputs.spec.ts diff --git a/packages/runtime/src/model-runner/inputs.ts b/packages/runtime/src/_shared/inputs.ts similarity index 97% rename from packages/runtime/src/model-runner/inputs.ts rename to packages/runtime/src/_shared/inputs.ts index cd950547..cc4532b8 100644 --- a/packages/runtime/src/model-runner/inputs.ts +++ b/packages/runtime/src/_shared/inputs.ts @@ -1,6 +1,6 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -import type { InputVarId } from '../_shared' +import type { InputVarId } from './types' /** Callback functions that are called when the input value is changed. */ export interface InputCallbacks { diff --git a/packages/runtime/src/model-runner/outputs.spec.ts b/packages/runtime/src/_shared/outputs.spec.ts similarity index 100% rename from packages/runtime/src/model-runner/outputs.spec.ts rename to packages/runtime/src/_shared/outputs.spec.ts diff --git a/packages/runtime/src/model-runner/outputs.ts b/packages/runtime/src/_shared/outputs.ts similarity index 97% rename from packages/runtime/src/model-runner/outputs.ts rename to packages/runtime/src/_shared/outputs.ts index 92536df2..513d0ed1 100644 --- a/packages/runtime/src/model-runner/outputs.ts +++ b/packages/runtime/src/_shared/outputs.ts @@ -2,7 +2,9 @@ import type { Result } from 'neverthrow' import { ok, err } from 'neverthrow' -import type { OutputVarId, OutputVarSpec } from '../_shared' + +import type { OutputVarId } from './types' +import type { VarSpec } from './var-indices' /** Indicates the type of error encountered when parsing an outputs buffer. */ export type ParseError = 'invalid-point-count' @@ -71,7 +73,7 @@ export class Outputs { * @hidden This is not yet part of the public API; it is exposed here for use * in experimental testing tools. */ - public varSpecs?: OutputVarSpec[] + public varSpecs?: VarSpec[] /** * @param varIds The output variable identifiers. @@ -110,7 +112,7 @@ export class Outputs { * @hidden This is not yet part of the public API; it is exposed here for use * in experimental testing tools. */ - setVarSpecs(varSpecs: OutputVarSpec[]) { + setVarSpecs(varSpecs: VarSpec[]) { if (varSpecs.length !== this.varIds.length) { throw new Error('Length of output varSpecs must match that of varIds') } diff --git a/packages/runtime/src/_shared/types.ts b/packages/runtime/src/_shared/types.ts index 39c2f708..e88bcd51 100644 --- a/packages/runtime/src/_shared/types.ts +++ b/packages/runtime/src/_shared/types.ts @@ -1,18 +1,14 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund +/** + * A variable identifier string, as used in SDEverywhere. + * + * @hidden This is hidden for now; this will be exposed in a separate set of changes. + */ +export type VarId = string + /** An input variable identifier string, as used in SDEverywhere. */ export type InputVarId = string /** An output variable identifier string, as used in SDEverywhere. */ export type OutputVarId = string - -/** - * The variable index values for use with the optional output indices buffer. - * @hidden This is not yet part of the public API; it is exposed here for use in testing tools. - */ -export interface OutputVarSpec { - /** The variable index as used in the generated C code. */ - varIndex: number - /** The subscript index values as used in the generated C code. */ - subscriptIndices?: number[] -} diff --git a/packages/runtime/src/_shared/var-indices.ts b/packages/runtime/src/_shared/var-indices.ts new file mode 100644 index 00000000..a68bc4c8 --- /dev/null +++ b/packages/runtime/src/_shared/var-indices.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +/** + * For each variable specified in an indices buffer, there are 4 index values: + * varIndex + * subIndex0 + * subIndex1 + * subIndex2 + * NOTE: This value needs to match `INDICES_PER_VARIABLE` as defined in SDE's `model.c`. + * @hidden This is not part of the public API. + */ +export const indicesPerVariable = 4 + +/** + * The variable index values for use with the optional input/output indices buffer. + * @hidden This is not yet part of the public API; it is exposed here for use in testing tools. + */ +export interface VarSpec { + /** The variable index as used in the generated C code. */ + varIndex: number + /** The subscript index values as used in the generated C code. */ + subscriptIndices?: number[] +} + +/** + * @hidden This is a temporary type alias to allow existing code that used the old `OutputVarSpec` + * to continue to work with the renamed `VarSpec` type. + */ +export type OutputVarSpec = VarSpec + +/** + * @hidden This is not part of the public API; it is exposed here for use by + * the synchronous and asynchronous model runner implementations. + */ +export function updateVarIndices(indicesArray: Int32Array, varSpecs: VarSpec[]): void { + if (indicesArray.length < varSpecs.length * indicesPerVariable) { + throw new Error('Length of indicesArray must be large enough to accommodate the given varSpecs') + } + + // Write the indices to the buffer + let offset = 0 + for (const varSpec of varSpecs) { + const subCount = varSpec.subscriptIndices?.length || 0 + indicesArray[offset + 0] = varSpec.varIndex + indicesArray[offset + 1] = subCount > 0 ? varSpec.subscriptIndices[0] : 0 + indicesArray[offset + 2] = subCount > 1 ? varSpec.subscriptIndices[1] : 0 + indicesArray[offset + 3] = subCount > 2 ? varSpec.subscriptIndices[2] : 0 + offset += indicesPerVariable + } + + // Fill the remainder of the buffer with zeros + indicesArray.fill(0, offset) +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index dd3deeb4..cb94aaf7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -export type { InputVarId, OutputVarId, OutputVarSpec } from './_shared' +export * from './_shared' +export * from './runnable-model' export * from './wasm-model' export * from './model-runner' export * from './model-scheduler' diff --git a/packages/runtime/src/model-runner/index.ts b/packages/runtime/src/model-runner/index.ts index 7dedab88..6aba9649 100644 --- a/packages/runtime/src/model-runner/index.ts +++ b/packages/runtime/src/model-runner/index.ts @@ -1,11 +1,7 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -export * from './inputs' -export * from './outputs' export * from './model-runner' +export * from './synchronous-model-runner' /** @hidden This is not part of the public API; exposed only for use in testing tools. */ export * from './model-listing' - -/** @hidden This is not part of the public API; exposed only for use in performance testing. */ -export * from './perf' diff --git a/packages/runtime/src/model-runner/model-listing.spec.ts b/packages/runtime/src/model-runner/model-listing.spec.ts index 7774ee49..4d96bf03 100644 --- a/packages/runtime/src/model-runner/model-listing.spec.ts +++ b/packages/runtime/src/model-runner/model-listing.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { ModelListing } from './model-listing' -import { Outputs } from './outputs' +import { Outputs } from '../_shared/outputs' const json = ` { diff --git a/packages/runtime/src/model-runner/model-listing.ts b/packages/runtime/src/model-runner/model-listing.ts index f859abde..8b3355e7 100644 --- a/packages/runtime/src/model-runner/model-listing.ts +++ b/packages/runtime/src/model-runner/model-listing.ts @@ -1,7 +1,7 @@ // Copyright (c) 2023 Climate Interactive / New Venture Fund -import type { OutputVarId, OutputVarSpec } from '../_shared' -import { Outputs } from './outputs' +import type { OutputVarId, VarId, VarSpec } from '../_shared' +import { Outputs } from '../_shared' type SubscriptId = string type DimensionId = string @@ -35,7 +35,7 @@ interface Dimension { * in experimental testing tools. */ export class ModelListing { - public readonly varSpecs: Map = new Map() + public readonly varSpecs: Map = new Map() constructor(modelJsonString: string) { // Parse the model listing JSON (as written by `sde generate --list`) @@ -126,8 +126,8 @@ export class ModelListing { * @param varIds The variable IDs to include with the new `Outputs` instance. */ deriveOutputs(normalOutputs: Outputs, varIds: OutputVarId[]): Outputs { - // Look up an `OutputVarSpec` for each variable ID - const varSpecs: OutputVarSpec[] = [] + // Look up a `VarSpec` for each variable ID + const varSpecs: VarSpec[] = [] for (const varId of varIds) { const varSpec = this.varSpecs.get(varId) if (varSpec !== undefined) { diff --git a/packages/runtime/src/model-runner/model-runner.spec.ts b/packages/runtime/src/model-runner/model-runner.spec.ts deleted file mode 100644 index 0ed9e8e1..00000000 --- a/packages/runtime/src/model-runner/model-runner.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2022 Climate Interactive / New Venture Fund - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import type { WasmModule } from '../wasm-model' -import { initWasmModelAndBuffers } from '../wasm-model' - -import { createInputValue } from './inputs' -import type { ModelRunner } from './model-runner' -import { createWasmModelRunner } from './model-runner' - -function createMockWasmModel() { - // This is a mock WasmModule that is sufficient for testing the synchronous runner implementation - const heapI32 = new Int32Array(1000) - const heapF64 = new Float64Array(1000) - let mallocOffset = 0 - const wasmModule: WasmModule = { - cwrap: fname => { - // Return a mock implementation of each wrapped C function - switch (fname) { - case 'getMaxOutputIndices': - return () => 0 - case 'getInitialTime': - return () => 2000 - case 'getFinalTime': - return () => 2100 - case 'getSaveper': - return () => 1 - case 'runModelWithBuffers': - return (_inputsAddress: number, outputsAddress: number) => { - // The outputsAddress is in bytes, so convert to float64 offset - const outputsOffset = outputsAddress / 8 - // Store a value in 2000 for the first output series - heapF64.set([6], outputsOffset) - // Store a value in 2100 for the second output series - heapF64.set([7], outputsOffset + 201) - } - default: - throw new Error(`Unhandled call to cwrap with function name '${fname}'`) - } - }, - _malloc: lengthInBytes => { - const currentOffset = mallocOffset - mallocOffset += lengthInBytes - return currentOffset - }, - _free: () => undefined, - HEAP32: heapI32, - HEAPF64: heapF64 - } - return initWasmModelAndBuffers(wasmModule, 3, ['_output_1', '_output_2']) -} - -describe('createWasmModelRunner', () => { - let runner: ModelRunner - - beforeEach(async () => { - runner = createWasmModelRunner(createMockWasmModel()) - }) - - afterEach(async () => { - if (runner) { - await runner.terminate() - } - }) - - it('should run the model', async () => { - expect(runner).toBeDefined() - const inputs = [createInputValue('_input_1', 0), createInputValue('_input_2', 0), createInputValue('_input_3', 0)] - const inOutputs = runner.createOutputs() - const outOutputs = await runner.runModel(inputs, inOutputs) - expect(outOutputs).toBeDefined() - expect(outOutputs.runTimeInMillis).toBeGreaterThan(0) - expect(outOutputs.getSeriesForVar('_output_1').getValueAtTime(2000)).toBe(6) - expect(outOutputs.getSeriesForVar('_output_2').getValueAtTime(2100)).toBe(7) - }) - - it('should throw an error if runModel is called after the runner has been terminated', async () => { - expect(runner).toBeDefined() - - await runner.terminate() - - const outputs = runner.createOutputs() - await expect(runner.runModel([], outputs)).rejects.toThrow('Model runner has already been terminated') - }) -}) diff --git a/packages/runtime/src/model-runner/model-runner.ts b/packages/runtime/src/model-runner/model-runner.ts index 081cfc90..8ebb2980 100644 --- a/packages/runtime/src/model-runner/model-runner.ts +++ b/packages/runtime/src/model-runner/model-runner.ts @@ -1,10 +1,6 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -import type { WasmModelInitResult } from '../wasm-model' -import { updateOutputIndices } from '../wasm-model' -import type { InputValue } from './inputs' -import { Outputs } from './outputs' -import { perfElapsed, perfNow } from './perf' +import type { InputValue, Outputs } from '../_shared' /** * Abstraction that allows for running the wasm model on the JS thread @@ -26,7 +22,7 @@ export interface ModelRunner { * @param outputs The structure into which the model outputs will be stored. * @return A promise that resolves with the outputs when the model run is complete. */ - runModel(inputs: InputValue[], outputs: Outputs): Promise + runModel(inputs: (number | InputValue)[], outputs: Outputs): Promise /** * Run the model synchronously. @@ -38,7 +34,7 @@ export interface ModelRunner { * @hidden This is only intended for internal use; some implementations may not support * running the model synchronously, in which case this will be undefined. */ - runModelSync?(inputs: InputValue[], outputs: Outputs): Outputs + runModelSync?(inputs: (number | InputValue)[], outputs: Outputs): Outputs /** * Terminate the runner by releasing underlying resources (e.g., the worker thread or @@ -46,80 +42,3 @@ export interface ModelRunner { */ terminate(): Promise } - -/** - * Create a `ModelRunner` that runs the given wasm model on the JS thread. - * - * @param wasmResult The result of initializing the wasm model. - */ -export function createWasmModelRunner(wasmResult: WasmModelInitResult): ModelRunner { - // Create views on the wasm buffers - const wasmModel = wasmResult.model - const inputsBuffer = wasmResult.inputsBuffer - const inputsArray = inputsBuffer.getArrayView() - const outputsBuffer = wasmResult.outputsBuffer - const outputsArray = outputsBuffer.getArrayView() - const outputIndicesBuffer = wasmResult.outputIndicesBuffer - const outputIndicesArray = outputIndicesBuffer?.getArrayView() - const rowLength = wasmModel.numSavePoints - - // Disallow `runModel` after the runner has been terminated - let terminated = false - - const runModelSync = (inputs: InputValue[], outputs: Outputs) => { - // Capture the current set of input values into the reusable buffer - let i = 0 - for (const input of inputs) { - inputsArray[i++] = input.get() - } - - // Update the output indices, if needed - const outputSpecs = outputs.varSpecs - let useIndices: boolean - if (outputIndicesArray && outputSpecs !== undefined && outputSpecs.length > 0) { - updateOutputIndices(outputIndicesArray, outputSpecs) - useIndices = true - } else { - useIndices = false - } - - // Run the model - const t0 = perfNow() - wasmModel.runModel(inputsBuffer, outputsBuffer, useIndices ? outputIndicesBuffer : undefined) - outputs.runTimeInMillis = perfElapsed(t0) - - // Capture the outputs array by copying the data into the given `Outputs` - // data structure - outputs.updateFromBuffer(outputsArray, rowLength) - - return outputs - } - - return { - createOutputs: () => { - return new Outputs(wasmResult.outputVarIds, wasmModel.startTime, wasmModel.endTime, wasmModel.saveFreq) - }, - - runModel: (inputs, outputs) => { - if (terminated) { - return Promise.reject(new Error('Model runner has already been terminated')) - } - return Promise.resolve(runModelSync(inputs, outputs)) - }, - - runModelSync: (inputs, outputs) => { - if (terminated) { - throw new Error('Model runner has already been terminated') - } - return runModelSync(inputs, outputs) - }, - - terminate: () => { - if (!terminated) { - // TODO: Release wasm-related resources (module or buffers) - terminated = true - } - return Promise.resolve() - } - } -} diff --git a/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts b/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts new file mode 100644 index 00000000..e5f9c4dc --- /dev/null +++ b/packages/runtime/src/model-runner/synchronous-model-runner.spec.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createInputValue } from '../_shared' + +import type { WasmModule } from '../wasm-model' +import { initWasmModel } from '../wasm-model' + +import type { RunnableModel } from '../runnable-model' +import { BaseRunnableModel } from '../runnable-model/base-runnable-model' + +import type { ModelRunner } from './model-runner' +import { createSynchronousModelRunner } from './synchronous-model-runner' +import { ModelListing } from './model-listing' + +function createMockWasmModel() { + // This is a mock WasmModule that is sufficient for testing the synchronous runner implementation + const heapI32 = new Int32Array(1000) + const heapF64 = new Float64Array(1000) + let mallocOffset = 0 + const wasmModule: WasmModule = { + cwrap: fname => { + // Return a mock implementation of each wrapped C function + switch (fname) { + case 'getInitialTime': + return () => 2000 + case 'getFinalTime': + return () => 2002 + case 'getSaveper': + return () => 1 + case 'runModelWithBuffers': + return (inputsAddress: number, outputsAddress: number, outputIndicesAddress: number) => { + // These address values are in bytes, so convert to float64 offset + const inputsOffset = inputsAddress / 8 + const outputsOffset = outputsAddress / 8 + + // This address is in bytes too, so convert to int32 offset + const outputIndicesOffset = outputIndicesAddress / 4 + + // Verify inputs + const inputs = heapF64.slice(inputsOffset, inputsOffset + 3) + expect(inputs).toEqual(new Float64Array([7, 8, 9])) + + if (outputIndicesAddress === 0) { + // Store 3 values for the _output_1, and 3 for _output_2 + heapF64.set([1, 2, 3, 4, 5, 6], outputsOffset) + } else { + // Verify output indices + const outputIndices = heapI32.slice(outputIndicesOffset, outputIndicesOffset + 4 * 4) + expect(outputIndices).toEqual( + new Int32Array([ + // _x + 3, 0, 0, 0, + // _output_2 + 2, 0, 0, 0, + // _output_1 + 1, 0, 0, 0, + // (zero terminator) + 0, 0, 0, 0 + ]) + ) + + // Store 3 values for each of the three variables + heapF64.set([7, 8, 9, 4, 5, 6, 1, 2, 3], outputsOffset) + } + } + default: + throw new Error(`Unhandled call to cwrap with function name '${fname}'`) + } + }, + _malloc: lengthInBytes => { + const currentOffset = mallocOffset + mallocOffset += lengthInBytes + return currentOffset + }, + _free: () => undefined, + HEAP32: heapI32, + HEAPF64: heapF64 + } + return initWasmModel(wasmModule, ['_output_1', '_output_2']) +} + +function createMockJsRunnableModel(): RunnableModel { + return new BaseRunnableModel({ + startTime: 2000, + endTime: 2002, + saveFreq: 1, + numSavePoints: 3, + outputVarIds: ['_output_1', '_output_2'], + onRunModel: (inputs, outputs, outputIndices) => { + // Verify inputs + expect(inputs).toEqual(new Float64Array([7, 8, 9])) + + if (outputIndices === undefined) { + // Store 3 values for the _output_1, and 3 for _output_2 + outputs.set([1, 2, 3, 4, 5, 6]) + } else { + // Verify output indices + expect(outputIndices).toEqual( + new Int32Array([ + // _x + 3, 0, 0, 0, + // _output_2 + 2, 0, 0, 0, + // _output_1 + 1, 0, 0, 0, + // (zero terminator) + 0, 0, 0, 0 + ]) + ) + + // Store 3 values for each of the three variables + outputs.set([7, 8, 9, 4, 5, 6, 1, 2, 3]) + } + } + }) +} + +const p = (x: number, y: number) => { + return { + x, + y + } +} + +describe.each([ + { kind: 'wasm', model: createMockWasmModel() }, + { kind: 'js', model: createMockJsRunnableModel() } +])('createSynchronousModelRunner (with mock $kind model)', ({ model }) => { + let runner: ModelRunner + + beforeEach(async () => { + runner = createSynchronousModelRunner(model) + }) + + afterEach(async () => { + if (runner) { + await runner.terminate() + } + }) + + it('should run the model (simple case with inputs and outputs only)', async () => { + expect(runner).toBeDefined() + const inputs = [createInputValue('_input_1', 7), createInputValue('_input_2', 8), createInputValue('_input_3', 9)] + const inOutputs = runner.createOutputs() + const outOutputs = await runner.runModel(inputs, inOutputs) + expect(outOutputs).toBeDefined() + expect(outOutputs.runTimeInMillis).toBeGreaterThan(0) + expect(outOutputs.getSeriesForVar('_output_1').points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outOutputs.getSeriesForVar('_output_2').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + }) + + it('should run the model (when output var specs are included)', async () => { + const json = ` +{ + "dimensions": [ + ], + "variables": [ + { + "refId": "_output_1", + "varName": "_output_1", + "varIndex": 1 + }, + { + "refId": "_output_2", + "varName": "_output_2", + "varIndex": 2 + }, + { + "refId": "_x", + "varName": "_x", + "varIndex": 3 + } + ] +} +` + + const listing = new ModelListing(json) + const inputs = [7, 8, 9] + const normalOutputs = runner.createOutputs() + const implOutputs = listing.deriveOutputs(normalOutputs, ['_x', '_output_2', '_output_1']) + const outOutputs = await runner.runModel(inputs, implOutputs) + expect(outOutputs).toBeDefined() + expect(outOutputs.runTimeInMillis).toBeGreaterThan(0) + expect(outOutputs.getSeriesForVar('_x').points).toEqual([p(2000, 7), p(2001, 8), p(2002, 9)]) + expect(outOutputs.getSeriesForVar('_output_2').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + expect(outOutputs.getSeriesForVar('_output_1').points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + }) + + it('should throw an error if runModel is called after the runner has been terminated', async () => { + expect(runner).toBeDefined() + + await runner.terminate() + + const outputs = runner.createOutputs() + await expect(runner.runModel([], outputs)).rejects.toThrow('Model runner has already been terminated') + }) +}) diff --git a/packages/runtime/src/model-runner/synchronous-model-runner.ts b/packages/runtime/src/model-runner/synchronous-model-runner.ts new file mode 100644 index 00000000..e809d2fb --- /dev/null +++ b/packages/runtime/src/model-runner/synchronous-model-runner.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import { type InputValue, Outputs } from '../_shared' +import { ReferencedRunModelParams } from '../runnable-model' +import type { RunnableModel } from '../runnable-model' +import type { WasmModelInitResult } from '../wasm-model' +import type { ModelRunner } from './model-runner' + +/** + * Create a `ModelRunner` that runs the given model on the JS thread. + * + * @hidden This is the new replacement for `createWasmModelRunner`; this will be + * exposed (and the old one deprecated) in a separate set of changes. + * + * @param model The runnable model instance. + */ +export function createSynchronousModelRunner(model: RunnableModel): ModelRunner { + // Maintain a `ReferencedRunModelParams` instance that holds the I/O parameters + const params = new ReferencedRunModelParams() + + // Disallow `runModel` after the runner has been terminated + let terminated = false + + const runModelSync = (inputs: (InputValue | number)[], outputs: Outputs) => { + // Update the I/O parameters + params.updateFromParams(inputs, outputs) + + // Run the model synchronously using those parameters + model.runModel(params) + + return outputs + } + + return { + createOutputs: () => { + return new Outputs(model.outputVarIds, model.startTime, model.endTime, model.saveFreq) + }, + + runModel: (inputs, outputs) => { + if (terminated) { + return Promise.reject(new Error('Model runner has already been terminated')) + } + return Promise.resolve(runModelSync(inputs, outputs)) + }, + + runModelSync: (inputs, outputs) => { + if (terminated) { + throw new Error('Model runner has already been terminated') + } + return runModelSync(inputs, outputs) + }, + + terminate: async () => { + if (!terminated) { + model.terminate() + terminated = true + } + } + } +} + +/** + * Create a `ModelRunner` that runs the given wasm model on the JS thread. + * + * @param wasmResult The result of initializing the wasm model. + */ +export function createWasmModelRunner(wasmResult: WasmModelInitResult): ModelRunner { + return createSynchronousModelRunner(wasmResult.model) +} diff --git a/packages/runtime/src/model-scheduler/model-scheduler.spec.ts b/packages/runtime/src/model-scheduler/model-scheduler.spec.ts index eae7aea5..09b7bb65 100644 --- a/packages/runtime/src/model-scheduler/model-scheduler.spec.ts +++ b/packages/runtime/src/model-scheduler/model-scheduler.spec.ts @@ -2,10 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createInputValue } from '../model-runner/inputs' -import type { ModelRunner } from '../model-runner/model-runner' -import { Outputs } from '../model-runner/outputs' - +import { createInputValue, Outputs, type InputValue } from '../_shared' +import type { ModelRunner } from '../model-runner' import { ModelScheduler } from './model-scheduler' describe('ModelScheduler', () => { @@ -20,8 +18,9 @@ describe('ModelScheduler', () => { return new Outputs(['_output_1', '_output_2'], 2000, 2100, 1) }, runModel: async (inputs, outputs) => { - const input1 = inputs.find(i => i.varId === '_input_1') - const input2 = inputs.find(i => i.varId === '_input_2') + // TODO: Update this when we change ModelScheduler to pass number[] instead of InputValue[] + const input1 = inputs[0] as InputValue + const input2 = inputs[1] as InputValue outputs.getSeriesForVar('_output_1').points[0].y = input1.get() outputs.getSeriesForVar('_output_2').points[100].y = input2.get() * 2 return outputs @@ -72,8 +71,9 @@ describe('ModelScheduler', () => { // over timing (pretend that the model takes 20ms to run) return new Promise(resolve => { setTimeout(() => { - const input1 = inputs.find(i => i.varId === '_input_1') - const input2 = inputs.find(i => i.varId === '_input_2') + // TODO: Update this when we change ModelScheduler to pass number[] instead of InputValue[] + const input1 = inputs[0] as InputValue + const input2 = inputs[1] as InputValue outputs.getSeriesForVar('_output_1').points[0].y = input1.get() outputs.getSeriesForVar('_output_2').points[100].y = input2.get() * 2 resolve(outputs) diff --git a/packages/runtime/src/model-scheduler/model-scheduler.ts b/packages/runtime/src/model-scheduler/model-scheduler.ts index 2cf94ada..3c2e235f 100644 --- a/packages/runtime/src/model-scheduler/model-scheduler.ts +++ b/packages/runtime/src/model-scheduler/model-scheduler.ts @@ -1,7 +1,7 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -import type { InputVarId } from '../_shared' -import type { InputValue, ModelRunner, Outputs } from '../model-runner' +import type { InputValue, InputVarId, Outputs } from '../_shared' +import type { ModelRunner } from '../model-runner' /** * A high-level interface that schedules running of the underlying `WasmModel`. diff --git a/packages/runtime/src/perf/index.ts b/packages/runtime/src/perf/index.ts new file mode 100644 index 00000000..9d266b96 --- /dev/null +++ b/packages/runtime/src/perf/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +/** @hidden This is not part of the public API; exposed only for use in performance testing. */ +export * from './perf' diff --git a/packages/runtime/src/model-runner/perf.ts b/packages/runtime/src/perf/perf.ts similarity index 100% rename from packages/runtime/src/model-runner/perf.ts rename to packages/runtime/src/perf/perf.ts diff --git a/packages/runtime/src/runnable-model/base-runnable-model.ts b/packages/runtime/src/runnable-model/base-runnable-model.ts new file mode 100644 index 00000000..332989f9 --- /dev/null +++ b/packages/runtime/src/runnable-model/base-runnable-model.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import type { OutputVarId } from '../_shared' +import { perfElapsed, perfNow } from '../perf' +import type { RunModelParams } from './run-model-params' +import type { RunnableModel } from './runnable-model' + +/** + * @hidden This is not part of the public API; for internal use only. + */ +export type OnRunModelFunc = ( + inputs: Float64Array, + outputs: Float64Array, + outputIndices: Int32Array | undefined +) => void + +/** + * A base implementation of the `RunnableModel` interface that takes care + * of managing internal buffers and copying values to/from the `RunModelParams`. + * + * The `onRunModel` function allows an implementation of the core model run loop + * to deal with typed arrays only. + * + * @hidden This is not part of the public API; for internal use only. + */ +export class BaseRunnableModel implements RunnableModel { + public readonly startTime: number + public readonly endTime: number + public readonly saveFreq: number + public readonly numSavePoints: number + public readonly outputVarIds: OutputVarId[] + + private readonly onRunModel: OnRunModelFunc + + private inputs: Float64Array + private outputs: Float64Array + private outputIndices: Int32Array + + constructor(options: { + startTime: number + endTime: number + saveFreq: number + numSavePoints: number + outputVarIds: OutputVarId[] + onRunModel: OnRunModelFunc + }) { + this.startTime = options.startTime + this.endTime = options.endTime + this.saveFreq = options.saveFreq + this.numSavePoints = options.numSavePoints + this.outputVarIds = options.outputVarIds + this.onRunModel = options.onRunModel + } + + // from RunnableModel interface + runModel(params: RunModelParams): void { + // Get a reference to the inputs array, or copy into a new one if needed + let inputsArray = params.getInputs() + if (inputsArray === undefined) { + // The inputs are not accessible in an array, so copy into a new array that we control + params.copyInputs(this.inputs, numElements => { + this.inputs = new Float64Array(numElements) + return this.inputs + }) + inputsArray = this.inputs + } + + // Get a reference to the output indices array, or copy into a new one if needed + let outputIndicesArray = params.getOutputIndices() + if (outputIndicesArray === undefined && params.getOutputIndicesLength() > 0) { + // The indices are not accessible in an array, so copy into a new array that we control + params.copyOutputIndices(this.outputIndices, numElements => { + this.outputIndices = new Int32Array(numElements) + return this.outputIndices + }) + outputIndicesArray = this.outputIndices + } + + // Allocate (or reallocate) the array that will receive the outputs + // TODO: If `params.getOutputsObject` returns an `Outputs` instance, we can + // write directly into that instead of creating a separate array + const outputsLengthInElements = params.getOutputsLength() + if (this.outputs === undefined || this.outputs.length < outputsLengthInElements) { + this.outputs = new Float64Array(outputsLengthInElements) + } + const outputsArray = this.outputs + + // Run the model + const t0 = perfNow() + this.onRunModel?.(inputsArray, outputsArray, outputIndicesArray) + const elapsed = perfElapsed(t0) + + // Copy the outputs that were stored into our array back to the `RunModelParams` + params.storeOutputs(outputsArray) + + // Store the elapsed time in the `RunModelParams` + params.storeElapsedTime(elapsed) + } + + // from RunnableModel interface + terminate(): void { + // No-op + } +} diff --git a/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts b/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts new file mode 100644 index 00000000..04c62d75 --- /dev/null +++ b/packages/runtime/src/runnable-model/buffered-run-model-params.spec.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import { describe, expect, it } from 'vitest' + +import { Outputs } from '../_shared' + +import { BufferedRunModelParams } from './buffered-run-model-params' +import { ModelListing } from '../model-runner' + +const json = ` +{ + "dimensions": [ + ], + "variables": [ + { + "refId": "_a", + "varName": "_a", + "varIndex": 1 + }, + { + "refId": "_b", + "varName": "_b", + "varIndex": 2 + }, + { + "refId": "_x", + "varName": "_x", + "varIndex": 3 + }, + { + "refId": "_y", + "varName": "_y", + "varIndex": 4 + } + ] +} +` + +const p = (x: number, y: number) => { + return { + x, + y + } +} + +describe('BufferedRunModelParams', () => { + it('should update buffer (simple case with inputs and outputs only)', () => { + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + // Update the first instance from params + const params1 = new BufferedRunModelParams() + params1.updateFromParams(inputs, outputs) + expect(params1.getInputs()).toEqual(new Float64Array([1, 2, 3])) + expect(params1.getOutputsLength()).toEqual(6) + expect(params1.getOutputs()).toEqual(new Float64Array([0, 0, 0, 0, 0, 0])) + expect(params1.getOutputsObject()).toBeUndefined() + expect(params1.getOutputIndices()).toBeUndefined() + + // Store some output values + params1.getOutputs().set([1, 2, 3, 4, 5, 6]) + + // Update the second instance from the encoded buffer and verify that the + // arrays are identical + const params2 = new BufferedRunModelParams() + const restoreAndVerify = () => { + params2.updateFromEncodedBuffer(params1.getEncodedBuffer()) + expect(params2.getInputs()).toEqual(params1.getInputs()) + expect(params2.getOutputsLength()).toEqual(params1.getOutputsLength()) + expect(params2.getOutputs()).toEqual(params1.getOutputs()) + expect(params2.getOutputsObject()).toEqual(params1.getOutputsObject()) + expect(params2.getOutputIndices()).toEqual(params1.getOutputIndices()) + } + restoreAndVerify() + }) + + it('should update buffer (when output var specs are included)', () => { + const listing = new ModelListing(json) + + const inputs = [1, 2, 3] + const normalOutputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + const implOutputs = listing.deriveOutputs(normalOutputs, ['_x', '_a', '_b']) + + // Update using the normal outputs (which includes only 2 variables) + const params1 = new BufferedRunModelParams() + params1.updateFromParams(inputs, normalOutputs) + expect(params1.getInputs()).toEqual(new Float64Array([1, 2, 3])) + expect(params1.getOutputsLength()).toEqual(6) + expect(params1.getOutputs()).toEqual(new Float64Array([0, 0, 0, 0, 0, 0])) + expect(params1.getOutputsObject()).toBeUndefined() + expect(params1.getOutputIndices()).toBeUndefined() + + // Create a second instance that can be used to verify roundtrip case (restoring + // from an encoded buffer) + const params2 = new BufferedRunModelParams() + const restoreAndVerify = () => { + params2.updateFromEncodedBuffer(params1.getEncodedBuffer()) + expect(params2.getInputs()).toEqual(params1.getInputs()) + expect(params2.getOutputsLength()).toEqual(params1.getOutputsLength()) + expect(params2.getOutputs()).toEqual(params1.getOutputs()) + expect(params2.getOutputsObject()).toEqual(params1.getOutputsObject()) + expect(params2.getOutputIndices()).toEqual(params1.getOutputIndices()) + } + + // Verify that second instance matches the first when updated from encoded buffer + restoreAndVerify() + + // Next update using the impl outputs (which includes 3 variables and therefore + // requires increasing the length of the outputs array) + params1.updateFromParams(inputs, implOutputs) + expect(params1.getInputs()).toEqual(new Float64Array([1, 2, 3])) + expect(params1.getOutputsLength()).toEqual(9) + expect(params1.getOutputIndices()).toEqual( + new Int32Array([ + // _x + 3, 0, 0, 0, + // _a + 1, 0, 0, 0, + // _b + 2, 0, 0, 0, + // (zero terminator) + 0, 0, 0, 0 + ]) + ) + + // Verify that second instance matches the first when updated from encoded buffer + restoreAndVerify() + + // Next update again using the normal outputs; the outputs array should be + // shorter since it only needs to hold values for 2 variables) and the output + // indices array should be undefined since it is not active for this run + params1.updateFromParams(inputs, normalOutputs) + expect(params1.getInputs()).toEqual(new Float64Array([1, 2, 3])) + expect(params1.getOutputsLength()).toEqual(6) + expect(params1.getOutputIndices()).toBeUndefined() + + // Verify that second instance matches the first when updated from encoded buffer + restoreAndVerify() + }) + + it('should copy inputs', () => { + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const params = new BufferedRunModelParams() + params.updateFromParams(inputs, outputs) + + let array: Float64Array + const create = (numElements: number) => { + array = new Float64Array(numElements) + return array + } + + // Verify case where existing array is undefined + params.copyInputs(undefined, create) + expect(array).toEqual(new Float64Array([1, 2, 3])) + + // Verify case where existing array is too small + array = new Float64Array(2) + params.copyInputs(array, create) + expect(array).toEqual(new Float64Array([1, 2, 3])) + + // Verify case where existing array is large enough + array = new Float64Array([6, 6, 6, 6]) + params.copyInputs(array, create) + expect(array).toEqual(new Float64Array([1, 2, 3, 6])) + }) + + it('should copy output indices', () => { + const listing = new ModelListing(json) + const inputs = [1, 2, 3] + const normalOutputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + const implOutputs = listing.deriveOutputs(normalOutputs, ['_x', '_a', '_b']) + + const params = new BufferedRunModelParams() + params.updateFromParams(inputs, implOutputs) + + const expectedIndices = new Int32Array([ + // _x + 3, 0, 0, 0, + // _a + 1, 0, 0, 0, + // _b + 2, 0, 0, 0, + // (zero terminator) + 0, 0, 0, 0 + ]) + + let array: Int32Array + const create = (numElements: number) => { + array = new Int32Array(numElements) + return array + } + + // Verify case where existing array is undefined + params.copyOutputIndices(undefined, create) + expect(array).toEqual(expectedIndices) + + // Verify case where existing array is too small + array = new Int32Array(2) + params.copyOutputIndices(array, create) + expect(array).toEqual(expectedIndices) + + // Verify case where existing array is large enough + array = new Int32Array(20).fill(6) + params.copyOutputIndices(array, create) + expect(array).toEqual( + new Int32Array([ + // _x + 3, 0, 0, 0, + // _a + 1, 0, 0, 0, + // _b + 2, 0, 0, 0, + // (zero terminator) + 0, 0, 0, 0, + // (existing data) + 6, 6, 6, 6 + ]) + ) + }) + + it('should store output values from the model run', () => { + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const runnerParams = new BufferedRunModelParams() + runnerParams.updateFromParams(inputs, outputs) + + const workerParams = new BufferedRunModelParams() + workerParams.updateFromEncodedBuffer(runnerParams.getEncodedBuffer()) + + // Pretend that the model writes the following values to its outputs buffer then + // calls the `store` methods + const outputsArray = new Float64Array([1, 2, 3, 4, 5, 6]) + workerParams.storeElapsedTime(42) + workerParams.storeOutputs(outputsArray) + + // Verify that the elapsed time can be accessed in the runner params + expect(runnerParams.getElapsedTime()).toBe(42) + + // Verify that the outputs buffer in the runner params contains the correct values + expect(runnerParams.getOutputs()).toEqual(new Float64Array([1, 2, 3, 4, 5, 6])) + + // Copy the outputs buffer to the `Outputs` instance and verify the values + runnerParams.finalizeOutputs(outputs) + expect(outputs.getSeriesForVar('_x').points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outputs.getSeriesForVar('_y').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + expect(outputs.runTimeInMillis).toBe(42) + }) +}) diff --git a/packages/runtime/src/runnable-model/buffered-run-model-params.ts b/packages/runtime/src/runnable-model/buffered-run-model-params.ts new file mode 100644 index 00000000..1493ba9b --- /dev/null +++ b/packages/runtime/src/runnable-model/buffered-run-model-params.ts @@ -0,0 +1,336 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import { indicesPerVariable, updateVarIndices, type InputValue, type Outputs } from '../_shared' +import type { RunModelParams } from './run-model-params' + +const headerLengthInElements = 16 +const extrasLengthInElements = 1 + +interface Section { + /** The view on the section of the `encoded` buffer, or undefined if the section is empty. */ + view?: ArrayType + + /** The byte offset of the section. */ + offsetInBytes: number + + /** The length (in elements) of the section. */ + lengthInElements: number + + /** + * Update the view for this section. + */ + update(encoded: ArrayBuffer, offsetInBytes: number, lengthInElements: number): void +} + +class Int32Section implements Section { + view?: Int32Array + offsetInBytes = 0 + lengthInElements = 0 + update(encoded: ArrayBuffer, offsetInBytes: number, lengthInElements: number): void { + this.view = lengthInElements > 0 ? new Int32Array(encoded, offsetInBytes, lengthInElements) : undefined + this.offsetInBytes = offsetInBytes + this.lengthInElements = lengthInElements + } +} + +class Float64Section implements Section { + view?: Float64Array + offsetInBytes = 0 + lengthInElements = 0 + update(encoded: ArrayBuffer, offsetInBytes: number, lengthInElements: number): void { + this.view = lengthInElements > 0 ? new Float64Array(encoded, offsetInBytes, lengthInElements) : undefined + this.offsetInBytes = offsetInBytes + this.lengthInElements = lengthInElements + } +} + +/** + * An implementation of `RunModelParams` that copies the input and output arrays into a single, + * combined buffer. This implementation is designed to work with an asynchronous `ModelRunner` + * implementation because the buffer can be transferred to/from a Web Worker or Node.js worker + * thread without copying (if it is marked `Transferable`). + * + * @hidden This is not yet exposed in the public API; it is currently only used by + * the implementations of the `RunnableModel` interface. + */ +export class BufferedRunModelParams implements RunModelParams { + /** + * The array that holds all input and output values. This is grown as needed. The memory + * layout of the buffer is as follows: + * header + * extras (holds elapsed time, etc) + * inputs + * outputs + * outputIndices + */ + private encoded: ArrayBuffer + + /** + * The header section of the `encoded` buffer. The header declares the byte offset and length + * (in elements) of each section of the buffer. + */ + private readonly header = new Int32Section() + + /** The extras section of the `encoded` buffer (holds elapsed time, etc). */ + private readonly extras = new Float64Section() + + /** The inputs section of the `encoded` buffer. */ + private readonly inputs = new Float64Section() + + /** The outputs section of the `encoded` buffer. */ + private readonly outputs = new Float64Section() + + /** The output indices section of the `encoded` buffer. */ + private readonly outputIndices = new Int32Section() + + /** + * Return the encoded buffer from this instance, which can be passed to `updateFromEncodedBuffer`. + */ + getEncodedBuffer(): ArrayBuffer { + return this.encoded + } + + // from RunModelParams interface + getInputs(): Float64Array | undefined { + return this.inputs.view + } + + // from RunModelParams interface + copyInputs(array: Float64Array | undefined, create: (numElements: number) => Float64Array): void { + // Allocate (or reallocate) an array, if needed + if (array === undefined || array.length < this.inputs.lengthInElements) { + array = create(this.inputs.lengthInElements) + } + + // Copy from the internal buffer to the array + array.set(this.inputs.view) + } + + // from RunModelParams interface + getOutputIndicesLength(): number { + return this.outputIndices.lengthInElements + } + + // from RunModelParams interface + getOutputIndices(): Int32Array | undefined { + return this.outputIndices.view + } + + // from RunModelParams interface + copyOutputIndices(array: Int32Array | undefined, create: (numElements: number) => Int32Array): void { + if (this.outputIndices.lengthInElements === 0) { + return + } + + // Allocate (or reallocate) an array, if needed + if (array === undefined || array.length < this.outputIndices.lengthInElements) { + array = create(this.outputIndices.lengthInElements) + } + + // Copy from the internal buffer to the array + array.set(this.outputIndices.view) + } + + // from RunModelParams interface + getOutputsLength(): number { + return this.outputs.lengthInElements + } + + // from RunModelParams interface + getOutputs(): Float64Array | undefined { + return this.outputs.view + } + + // from RunModelParams interface + getOutputsObject(): Outputs | undefined { + // This implementation does not keep a reference to the original `Outputs` instance, + // so we return undefined here + return undefined + } + + // from RunModelParams interface + storeOutputs(array: Float64Array): void { + // Copy from the given array to the internal buffer + this.outputs.view?.set(array) + } + + // from RunModelParams interface + getElapsedTime(): number { + return this.extras.view[0] + } + + // from RunModelParams interface + storeElapsedTime(elapsed: number): void { + // Store elapsed time in the extras section + this.extras.view[0] = elapsed + } + + /** + * Copy the outputs buffer to the given `Outputs` instance. This should be called + * after the `runModel` call has completed so that the output values are copied from + * the internal buffer to the `Outputs` instance that was passed to `runModel`. + * + * @param outputs The `Outputs` instance into which the output values will be copied. + */ + finalizeOutputs(outputs: Outputs): void { + // Copy the output values to the `Outputs` instance + if (this.outputs.view) { + outputs.updateFromBuffer(this.outputs.view, outputs.seriesLength) + } + + // Store the elapsed time value in the `Outputs` instance + outputs.runTimeInMillis = this.getElapsedTime() + } + + /** + * Update this instance using the parameters that are passed to a `runModel` call. + * + * @param inputs The model input values (must be in the same order as in the spec file). + * @param outputs The structure into which the model outputs will be stored. + */ + updateFromParams(inputs: (number | InputValue)[], outputs: Outputs): void { + // Determine the number of elements in each section + const inputsLengthInElements = inputs.length + const outputsLengthInElements = outputs.varIds.length * outputs.seriesLength + let outputIndicesLengthInElements: number + const outputVarSpecs = outputs.varSpecs + if (outputVarSpecs !== undefined && outputVarSpecs.length > 0) { + // The output indices buffer needs to include N elements for each var spec plus one + // additional "zero" element as a terminator + outputIndicesLengthInElements = (outputVarSpecs.length + 1) * indicesPerVariable + } else { + // Don't use the output indices buffer when output var specs are not provided + outputIndicesLengthInElements = 0 + } + + // Compute the byte offset and byte length of each section + let byteOffset = 0 + function section(kind: 'float64' | 'int32', lengthInElements: number): number { + // Start at the current byte offset + const sectionOffsetInBytes = byteOffset + + // Compute the section length. We round up to ensure 8 byte alignment, which is needed in order + // for each section's start offset to be aligned correctly. + const bytesPerElement = kind === 'float64' ? Float64Array.BYTES_PER_ELEMENT : Int32Array.BYTES_PER_ELEMENT + const requiredSectionLengthInBytes = Math.round(lengthInElements * bytesPerElement) + const alignedSectionLengthInBytes = Math.ceil(requiredSectionLengthInBytes / 8) * 8 + byteOffset += alignedSectionLengthInBytes + return sectionOffsetInBytes + } + const headerOffsetInBytes = section('int32', headerLengthInElements) + const extrasOffsetInBytes = section('float64', extrasLengthInElements) + const inputsOffsetInBytes = section('float64', inputsLengthInElements) + const outputsOffsetInBytes = section('float64', outputsLengthInElements) + const outputIndicesOffsetInBytes = section('int32', outputIndicesLengthInElements) + + // Get the total byte length + const requiredLengthInBytes = byteOffset + + // Create or grow the buffer, if needed + if (this.encoded === undefined || this.encoded.byteLength < requiredLengthInBytes) { + // Add some extra space at the end of the buffer to allow for sections to grow a bit without + // having to reallocate the entire buffer + const totalLengthInBytes = Math.ceil(requiredLengthInBytes * 1.2) + this.encoded = new ArrayBuffer(totalLengthInBytes) + + // Recreate the header view when the buffer changes + this.header.update(this.encoded, headerOffsetInBytes, headerLengthInElements) + } + + // Update the header + const headerView = this.header.view + let headerIndex = 0 + headerView[headerIndex++] = extrasOffsetInBytes + headerView[headerIndex++] = extrasLengthInElements + headerView[headerIndex++] = inputsOffsetInBytes + headerView[headerIndex++] = inputsLengthInElements + headerView[headerIndex++] = outputsOffsetInBytes + headerView[headerIndex++] = outputsLengthInElements + headerView[headerIndex++] = outputIndicesOffsetInBytes + headerView[headerIndex++] = outputIndicesLengthInElements + + // Update the views + // TODO: We can avoid recreating the views every time if buffer and section offset/length + // haven't changed + this.inputs.update(this.encoded, inputsOffsetInBytes, inputsLengthInElements) + this.extras.update(this.encoded, extrasOffsetInBytes, extrasLengthInElements) + this.outputs.update(this.encoded, outputsOffsetInBytes, outputsLengthInElements) + this.outputIndices.update(this.encoded, outputIndicesOffsetInBytes, outputIndicesLengthInElements) + + // Copy the input values into the internal buffer + // TODO: Throw an error if inputs.length is less than number of inputs declared + // in the spec (only in the case where useInputIndices is false) + const inputsView = this.inputs.view + for (let i = 0; i < inputs.length; i++) { + // XXX: The `inputs` array type used to be declared as `InputValue[]`, so some users + // may be relying on that, but it has now been simplified to `number[]`. For the time + // being, we will allow for either type and choose which one depending on the shape of + // the array elements. + const input = inputs[i] + if (typeof input === 'number') { + inputsView[i] = input + } else { + inputsView[i] = input.get() + } + } + + // Copy the the output indices into the internal buffer, if needed + if (this.outputIndices.view) { + updateVarIndices(this.outputIndices.view, outputVarSpecs) + } + } + + /** + * Update this instance using the values contained in the encoded buffer from another + * `BufferedRunModelParams` instance. + * + * @param buffer An encoded buffer returned by `getEncodedBuffer`. + */ + updateFromEncodedBuffer(buffer: ArrayBuffer): void { + // Verify that the buffer is long enough to contain the header section + const headerLengthInBytes = headerLengthInElements * Int32Array.BYTES_PER_ELEMENT + if (buffer.byteLength < headerLengthInBytes) { + throw new Error('Buffer must be long enough to contain header section') + } + + // Set the buffer + this.encoded = buffer + + // Rebuild the header + const headerOffsetInBytes = 0 + this.header.update(this.encoded, headerOffsetInBytes, headerLengthInElements) + + // Get the section offsets and lengths from the header + const headerView = this.header.view + let headerIndex = 0 + const extrasOffsetInBytes = headerView[headerIndex++] + const extrasLengthInElements = headerView[headerIndex++] + const inputsOffsetInBytes = headerView[headerIndex++] + const inputsLengthInElements = headerView[headerIndex++] + const outputsOffsetInBytes = headerView[headerIndex++] + const outputsLengthInElements = headerView[headerIndex++] + const outputIndicesOffsetInBytes = headerView[headerIndex++] + const outputIndicesLengthInElements = headerView[headerIndex++] + + // Verify that the buffer is long enough to contain all sections + const extrasLengthInBytes = extrasLengthInElements * Float64Array.BYTES_PER_ELEMENT + const inputsLengthInBytes = inputsLengthInElements * Float64Array.BYTES_PER_ELEMENT + const outputsLengthInBytes = outputsLengthInElements * Float64Array.BYTES_PER_ELEMENT + const outputIndicesLengthInBytes = outputIndicesLengthInElements * Int32Array.BYTES_PER_ELEMENT + const requiredLengthInBytes = + headerLengthInBytes + + extrasLengthInBytes + + inputsLengthInBytes + + outputsLengthInBytes + + outputIndicesLengthInBytes + if (buffer.byteLength < requiredLengthInBytes) { + throw new Error('Buffer must be long enough to contain sections declared in header') + } + + // Rebuild the sections according to the section offsets and lengths in the header + this.extras.update(this.encoded, extrasOffsetInBytes, extrasLengthInElements) + this.inputs.update(this.encoded, inputsOffsetInBytes, inputsLengthInElements) + this.outputs.update(this.encoded, outputsOffsetInBytes, outputsLengthInElements) + this.outputIndices.update(this.encoded, outputIndicesOffsetInBytes, outputIndicesLengthInElements) + } +} diff --git a/packages/runtime/src/runnable-model/index.ts b/packages/runtime/src/runnable-model/index.ts new file mode 100644 index 00000000..78789894 --- /dev/null +++ b/packages/runtime/src/runnable-model/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +export * from './run-model-params' +export * from './buffered-run-model-params' +export * from './referenced-run-model-params' + +export * from './runnable-model' diff --git a/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts b/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts new file mode 100644 index 00000000..4f26175f --- /dev/null +++ b/packages/runtime/src/runnable-model/referenced-run-model-params.spec.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import { describe, expect, it } from 'vitest' + +import { Outputs } from '../_shared' + +import { ReferencedRunModelParams } from './referenced-run-model-params' +import { ModelListing } from '../model-runner' + +const json = ` +{ + "dimensions": [ + ], + "variables": [ + { + "refId": "_a", + "varName": "_a", + "varIndex": 1 + }, + { + "refId": "_b", + "varName": "_b", + "varIndex": 2 + }, + { + "refId": "_x", + "varName": "_x", + "varIndex": 3 + }, + { + "refId": "_y", + "varName": "_y", + "varIndex": 4 + } + ] +} +` + +const p = (x: number, y: number) => { + return { + x, + y + } +} + +describe('ReferencedRunModelParams', () => { + it('should return correct values from accessors', () => { + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const params = new ReferencedRunModelParams() + params.updateFromParams(inputs, outputs) + + expect(params.getInputs()).toBeUndefined() + + expect(params.getOutputIndices()).toBeUndefined() + + expect(params.getOutputs()).toBeUndefined() + expect(params.getOutputsObject()).toBeDefined() + expect(params.getOutputsObject().varIds).toEqual(['_x', '_y']) + expect(params.getOutputsLength()).toBe(6) + }) + + it('should copy inputs', () => { + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const params = new ReferencedRunModelParams() + params.updateFromParams(inputs, outputs) + + let array: Float64Array + const create = (numElements: number) => { + array = new Float64Array(numElements) + return array + } + + // Verify case where existing array is undefined + params.copyInputs(undefined, create) + expect(array).toEqual(new Float64Array([1, 2, 3])) + + // Verify case where existing array is too small + array = new Float64Array(2) + params.copyInputs(array, create) + expect(array).toEqual(new Float64Array([1, 2, 3])) + + // Verify case where existing array is large enough + array = new Float64Array([6, 6, 6, 6]) + params.copyInputs(array, create) + expect(array).toEqual(new Float64Array([1, 2, 3, 6])) + }) + + it('should copy output indices', () => { + const listing = new ModelListing(json) + const inputs = [1, 2, 3] + const normalOutputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + const implOutputs = listing.deriveOutputs(normalOutputs, ['_x', '_a', '_b']) + + const params = new ReferencedRunModelParams() + params.updateFromParams(inputs, implOutputs) + + const expectedIndices = new Int32Array([ + // _x + 3, 0, 0, 0, + // _a + 1, 0, 0, 0, + // _b + 2, 0, 0, 0, + // (zero terminator) + 0, 0, 0, 0 + ]) + + let array: Int32Array + const create = (numElements: number) => { + array = new Int32Array(numElements) + return array + } + + // Verify case where existing array is undefined + params.copyOutputIndices(undefined, create) + expect(array).toEqual(expectedIndices) + + // Verify case where existing array is too small + array = new Int32Array(2) + params.copyOutputIndices(array, create) + expect(array).toEqual(expectedIndices) + + // Verify case where existing array is large enough + array = new Int32Array(20).fill(6) + params.copyOutputIndices(array, create) + expect(array).toEqual( + new Int32Array([ + // _x + 3, 0, 0, 0, + // _a + 1, 0, 0, 0, + // _b + 2, 0, 0, 0, + // (zero terminators) + 0, 0, 0, 0, 0, 0, 0, 0 + ]) + ) + }) + + it('should store output values from the model run', () => { + const inputs = [1, 2, 3] + const outputs = new Outputs(['_x', '_y'], 2000, 2002, 1) + + const params = new ReferencedRunModelParams() + params.updateFromParams(inputs, outputs) + + // Pretend that the model writes the following values to its buffer then + // calls the `store` methods + const outputsArray = new Float64Array([1, 2, 3, 4, 5, 6]) + params.storeElapsedTime(42) + params.storeOutputs(outputsArray) + + // Verify that the elapsed time can be accessed + expect(params.getElapsedTime()).toBe(42) + + // Verify that the `Outputs` instance is updated with the correct values + expect(outputs.varIds).toEqual(['_x', '_y']) + expect(outputs.getSeriesForVar('_x').points).toEqual([p(2000, 1), p(2001, 2), p(2002, 3)]) + expect(outputs.getSeriesForVar('_y').points).toEqual([p(2000, 4), p(2001, 5), p(2002, 6)]) + }) +}) diff --git a/packages/runtime/src/runnable-model/referenced-run-model-params.ts b/packages/runtime/src/runnable-model/referenced-run-model-params.ts new file mode 100644 index 00000000..1c277725 --- /dev/null +++ b/packages/runtime/src/runnable-model/referenced-run-model-params.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import { indicesPerVariable, updateVarIndices, type InputValue, type Outputs } from '../_shared' +import type { RunModelParams } from './run-model-params' + +/** + * An implementation of `RunModelParams` that keeps references to the `inputs` and + * `outputs` parameters that are passed to the `runModel` function. This implementation + * is best used with a synchronous `ModelRunner`. + * + * @hidden This is not yet exposed in the public API; it is currently only used by + * the implementations of the `RunnableModel` interface. + */ +export class ReferencedRunModelParams implements RunModelParams { + private inputs: (number | InputValue)[] + private outputs: Outputs + private outputsLengthInElements = 0 + private outputIndicesLengthInElements = 0 + + // from RunModelParams interface + getInputs(): Float64Array | undefined { + // This implementation does not keep a buffer of inputs, so we return undefined here + return undefined + } + + // from RunModelParams interface + copyInputs(array: Float64Array | undefined, create: (numElements: number) => Float64Array): void { + // Allocate (or reallocate) an array, if needed + const inputsLengthInElements = this.inputs.length + if (array === undefined || array.length < inputsLengthInElements) { + array = create(inputsLengthInElements) + } + + // Copy the input values into the provided array + for (let i = 0; i < this.inputs.length; i++) { + // XXX: The `inputs` array type used to be declared as `InputValue[]`, so some users + // may be relying on that, but it has now been simplified to `number[]`. For the time + // being, we will allow for either type and choose which one depending on the shape of + // the array elements. + const input = this.inputs[i] + if (typeof input === 'number') { + array[i] = input + } else { + array[i] = input.get() + } + } + } + + // from RunModelParams interface + getOutputIndicesLength(): number { + return this.outputIndicesLengthInElements + } + + // from RunModelParams interface + getOutputIndices(): Int32Array | undefined { + // This implementation does not keep a buffer of indices, so we return undefined here + return undefined + } + + // from RunModelParams interface + copyOutputIndices(array: Int32Array | undefined, create: (numElements: number) => Int32Array): void { + if (this.outputIndicesLengthInElements === 0) { + return + } + + // Allocate (or reallocate) an array, if needed + if (array === undefined || array.length < this.outputIndicesLengthInElements) { + array = create(this.outputIndicesLengthInElements) + } + + // Copy the output indices to the provided array + updateVarIndices(array, this.outputs.varSpecs) + } + + // from RunModelParams interface + getOutputsLength(): number { + return this.outputsLengthInElements + } + + // from RunModelParams interface + getOutputs(): Float64Array | undefined { + // This implementation does not keep a buffer of outputs, so we return undefined here + return undefined + } + + // from RunModelParams interface + getOutputsObject(): Outputs | undefined { + return this.outputs + } + + // from RunModelParams interface + storeOutputs(array: Float64Array): void { + // Update the `Outputs` instance with the values from the given array + if (this.outputs) { + const result = this.outputs.updateFromBuffer(array, this.outputs.seriesLength) + if (result.isErr()) { + throw new Error(`Failed to store outputs: ${result.error}`) + } + } + } + + // from RunModelParams interface + getElapsedTime(): number { + return this.outputs?.runTimeInMillis + } + + // from RunModelParams interface + storeElapsedTime(elapsed: number): void { + // Store the elapsed time value in the `Outputs` instance + if (this.outputs) { + this.outputs.runTimeInMillis = elapsed + } + } + + /** + * Update this instance using the parameters that are passed to a `runModel` call. + * + * @param inputs The model input values (must be in the same order as in the spec file). + * @param outputs The structure into which the model outputs will be stored. + */ + updateFromParams(inputs: (number | InputValue)[], outputs: Outputs): void { + // Save the latest parameters; these values will be accessed by the `RunnableModel` + // on demand (e.g., in the `copyInputs` method) + this.inputs = inputs + this.outputs = outputs + this.outputsLengthInElements = outputs.varIds.length * outputs.seriesLength + + // See if the output indices are needed + const outputVarSpecs = outputs.varSpecs + if (outputVarSpecs !== undefined && outputVarSpecs.length > 0) { + // The output indices buffer needs to include N elements for each var spec plus one + // additional "zero" element as a terminator + this.outputIndicesLengthInElements = (outputVarSpecs.length + 1) * indicesPerVariable + } else { + // Don't use the output indices buffer when output var specs are not provided + this.outputIndicesLengthInElements = 0 + } + } +} diff --git a/packages/runtime/src/runnable-model/run-model-params.ts b/packages/runtime/src/runnable-model/run-model-params.ts new file mode 100644 index 00000000..f0abeb7b --- /dev/null +++ b/packages/runtime/src/runnable-model/run-model-params.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import type { Outputs } from '../_shared' + +/** + * Encapsulates the parameters that are passed to a `runModel` call. + * + * @hidden This is not yet exposed in the public API; it is currently only used by + * the implementations of the `RunnableModel` interface. + */ +export interface RunModelParams { + /** + * Return the array containing the inputs, or undefined if the implementation does not + * have the inputs readily available in an array. If this returns undefined, use + * `copyInputs` to copy the inputs into a provided array. + */ + getInputs(): Float64Array | undefined + + /** + * Copy the input values into an array. + * + * @param array An existing array, or undefined. If `array` is undefined, or it is + * not large enough to hold the input values, the `create` function will be called + * to allocate a new array. + * @param create A function that allocates a new `Float64Array` with the given length. + */ + copyInputs(array: Float64Array | undefined, create: (numElements: number) => Float64Array): void + + /** + * Return the length (in elements) of the output indices array, or 0 if the indices are + * not active (i.e., if they were not included in the latest `runModel` call). + */ + getOutputIndicesLength(): number + + /** + * Return the array containing the output indices, or undefined if the implementation does not + * have the output indices readily available in an array. If this returns undefined, use + * `copyOutputIndices` to copy the output indices into a provided array. + */ + getOutputIndices(): Int32Array | undefined + + /** + * Copy the output indices into an array. + * + * @param array An existing array, or undefined. If `array` is undefined, or it is + * not large enough to hold the input values, the `create` function will be called + * to allocate a new array. + * @param create A function that allocates a new `Int32Array` with the given length. + */ + copyOutputIndices(array: Int32Array | undefined, create: (numElements: number) => Int32Array): void + + /** + * Return the length (in elements) of the array that will receive the outputs. + */ + getOutputsLength(): number + + /** + * Return the array containing the outputs, or undefined if the implementation does not + * have an array available for writing the outputs. + */ + getOutputs(): Float64Array | undefined + + /** + * Return the `Outputs` object, or undefined if the implementation does not keep a reference + * to the `Outputs` object that was passed to `runModel`. + */ + getOutputsObject(): Outputs | undefined + + /** + * Store the output values that were written by the model. This will be used to populate + * the `Outputs` object that was passed to the latest `runModel` call. + * + * @param array The array that contains the output values. + */ + storeOutputs(array: Float64Array): void + + /** + * Return the elapsed time (in milliseconds) of the model run. + */ + getElapsedTime(): number + + /** + * Store the elapsed time of the model run. + * + * @param elapsed The model run time, in milliseconds. + */ + storeElapsedTime(elapsed: number): void +} diff --git a/packages/runtime/src/runnable-model/runnable-model.ts b/packages/runtime/src/runnable-model/runnable-model.ts new file mode 100644 index 00000000..d6447014 --- /dev/null +++ b/packages/runtime/src/runnable-model/runnable-model.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2024 Climate Interactive / New Venture Fund + +import type { OutputVarId } from '../_shared' +import type { RunModelParams } from './run-model-params' + +/** + * This interface exposes the properties and functions that allow a `ModelRunner` + * implementation to run a model that was generated by the SDEverywhere transpiler. + * The `runModel` method will synchronously run the wrapped model with a provided + * set of input and output parameters. + * + * @hidden This is not yet exposed in the public API; it is currently only used by + * the implementations of the `RunnableModel` interface. + */ +export interface RunnableModel { + /** The start time for the model (aka `INITIAL TIME`). */ + readonly startTime: number + + /** The end time for the model (aka `FINAL TIME`). */ + readonly endTime: number + + /** The frequency with which output values are saved (aka `SAVEPER`). */ + readonly saveFreq: number + + /** The number of save points for each output. */ + readonly numSavePoints: number + + /** The output variable IDs for this model. */ + readonly outputVarIds: OutputVarId[] + + /** + * Run the model synchronously on the current thread. + * + * @param params The parameters that control the model run. + */ + runModel(params: RunModelParams): void + + /** + * Terminate the runner by releasing underlying resources (e.g., the worker thread or + * Wasm module/buffers). + */ + terminate(): void +} diff --git a/packages/runtime/src/wasm-model/index.ts b/packages/runtime/src/wasm-model/index.ts index 71decd44..e33a70d0 100644 --- a/packages/runtime/src/wasm-model/index.ts +++ b/packages/runtime/src/wasm-model/index.ts @@ -2,4 +2,4 @@ export * from './wasm-buffer' export * from './wasm-model' -export type { WasmModule } from './wasm-module' +export * from './wasm-module' diff --git a/packages/runtime/src/wasm-model/wasm-buffer.ts b/packages/runtime/src/wasm-model/wasm-buffer.ts index a0650a08..3600c06e 100644 --- a/packages/runtime/src/wasm-model/wasm-buffer.ts +++ b/packages/runtime/src/wasm-model/wasm-buffer.ts @@ -17,10 +17,16 @@ import type { WasmModule } from './wasm-module' export class WasmBuffer { /** * @param wasmModule The `WasmModule` used to initialize the memory. + * @param numElements The number of elements in the buffer. * @param byteOffset The byte offset within the wasm heap. * @param heapArray The array view on the underlying heap buffer. */ - constructor(private readonly wasmModule: WasmModule, private byteOffset: number, private heapArray: ArrType) {} + constructor( + private readonly wasmModule: WasmModule, + public numElements: number, + private byteOffset: number, + private heapArray: ArrType + ) {} /** * @return An `ArrType` view on the underlying heap buffer. @@ -42,7 +48,8 @@ export class WasmBuffer { */ dispose(): void { if (this.heapArray) { - this.wasmModule._free(this.byteOffset) + this.wasmModule._free?.(this.byteOffset) + this.numElements = undefined this.heapArray = undefined this.byteOffset = undefined } @@ -63,7 +70,7 @@ export function createInt32WasmBuffer(wasmModule: WasmModule, numElements: numbe const byteOffset = wasmModule._malloc(lengthInBytes) const elemOffset = byteOffset / elemSizeInBytes const heapArray = wasmModule.HEAP32.subarray(elemOffset, elemOffset + numElements) - return new WasmBuffer(wasmModule, byteOffset, heapArray) + return new WasmBuffer(wasmModule, numElements, byteOffset, heapArray) } /** @@ -80,5 +87,5 @@ export function createFloat64WasmBuffer(wasmModule: WasmModule, numElements: num const byteOffset = wasmModule._malloc(lengthInBytes) const elemOffset = byteOffset / elemSizeInBytes const heapArray = wasmModule.HEAPF64.subarray(elemOffset, elemOffset + numElements) - return new WasmBuffer(wasmModule, byteOffset, heapArray) + return new WasmBuffer(wasmModule, numElements, byteOffset, heapArray) } diff --git a/packages/runtime/src/wasm-model/wasm-model.ts b/packages/runtime/src/wasm-model/wasm-model.ts index 7f8e4ae1..3f50565a 100644 --- a/packages/runtime/src/wasm-model/wasm-model.ts +++ b/packages/runtime/src/wasm-model/wasm-model.ts @@ -1,24 +1,16 @@ // Copyright (c) 2020-2022 Climate Interactive / New Venture Fund -import type { OutputVarId, OutputVarSpec } from '../_shared' -import type { WasmBuffer } from './wasm-buffer' -import { createFloat64WasmBuffer, createInt32WasmBuffer } from './wasm-buffer' +import type { OutputVarId } from '../_shared' +import type { RunModelParams, RunnableModel } from '../runnable-model' +import { perfElapsed, perfNow } from '../perf' +import { createFloat64WasmBuffer, createInt32WasmBuffer, type WasmBuffer } from './wasm-buffer' import type { WasmModule } from './wasm-module' -// For each output variable specified in the indices buffer, there -// are 4 index values: -// varIndex -// subIndex0 -// subIndex1 -// subIndex2 -// NOTE: This value needs to match `INDICES_PER_OUTPUT` as defined in SDE's `model.c` -const indicesPerOutput = 4 - /** * An interface to the generated WebAssembly model. Allows for running the model with * a given set of input values, producing a set of output values. */ -export class WasmModel { +export class WasmModel implements RunnableModel { /** The start time for the model (aka `INITIAL TIME`). */ public readonly startTime: number /** The end time for the model (aka `FINAL TIME`). */ @@ -27,19 +19,20 @@ export class WasmModel { public readonly saveFreq: number /** The number of save points for each output. */ public readonly numSavePoints: number - /** - * The maximum number of output indices that can be passed for each run. - * @hidden This is not yet part of the public API; it is exposed here for use - * in experimental testing tools. - */ - public readonly maxOutputIndices: number + + // Reuse the wasm buffers. These buffers are allocated on demand and grown + // (reallocated) as needed. + private inputsBuffer: WasmBuffer + private outputsBuffer: WasmBuffer + private outputIndicesBuffer: WasmBuffer private readonly wasmRunModel: (inputsAddress: number, outputsAddress: number, outputIndicesAddress: number) => void /** * @param wasmModule The `WasmModule` that provides access to the native functions. + * @param outputVarIds The output variable IDs for this model. */ - constructor(wasmModule: WasmModule) { + constructor(private readonly wasmModule: WasmModule, public readonly outputVarIds: OutputVarId[]) { function getNumberValue(funcName: string): number { const wasmGetValue: () => number = wasmModule.cwrap(funcName, 'number', []) return wasmGetValue() @@ -48,53 +41,104 @@ export class WasmModel { this.endTime = getNumberValue('getFinalTime') this.saveFreq = getNumberValue('getSaveper') - // Note that `getMaxOutputIndices` is not yet official, so proceed if it is not exposed - try { - this.maxOutputIndices = getNumberValue('getMaxOutputIndices') - } catch (e) { - this.maxOutputIndices = 0 - } - // Each series will include one data point per "save", inclusive of the // start and end times this.numSavePoints = Math.round((this.endTime - this.startTime) / this.saveFreq) + 1 + // Make the native `runModelWithBuffers` function callable this.wasmRunModel = wasmModule.cwrap('runModelWithBuffers', null, ['number', 'number', 'number']) } - /** - * Run the model, using inputs from the `inputs` buffer, and writing outputs into - * the `outputs` buffer. - * - * @param inputs The buffer containing inputs in the order expected by the model. - * @param outputs The buffer into which the model will store output values. - * @param outputIndices The buffer used to control which variables are written to `outputs`. - */ - runModel( - inputs: WasmBuffer, - outputs: WasmBuffer, - outputIndices?: WasmBuffer - ): void { - this.wasmRunModel(inputs.getAddress(), outputs.getAddress(), outputIndices?.getAddress() || 0) + // from RunnableModel interface + runModel(params: RunModelParams): void { + // Note that for wasm models, we always need to allocate `WasmBuffer` instances to + // and copy data to/from them because only that kind of buffer can be passed to + // the `wasmRunModel` function. + + // Copy the inputs to the `WasmBuffer`. If we don't have an existing `WasmBuffer`, + // or the existing one is not big enough, the callback will allocate a new one. + params.copyInputs(this.inputsBuffer?.getArrayView(), numElements => { + this.inputsBuffer?.dispose() + this.inputsBuffer = createFloat64WasmBuffer(this.wasmModule, numElements) + return this.inputsBuffer.getArrayView() + }) + + let outputIndicesBuffer: WasmBuffer + if (params.getOutputIndicesLength() > 0) { + // Copy the output indices (if needed) to the `WasmBuffer`. If we don't have an + // existing `WasmBuffer`, or the existing one is not big enough, the callback + // will allocate a new one. + params.copyOutputIndices(this.outputIndicesBuffer?.getArrayView(), numElements => { + this.outputIndicesBuffer?.dispose() + this.outputIndicesBuffer = createInt32WasmBuffer(this.wasmModule, numElements) + return this.outputIndicesBuffer.getArrayView() + }) + outputIndicesBuffer = this.outputIndicesBuffer + } else { + // The output indices are not active + outputIndicesBuffer = undefined + } + + // Allocate (or reallocate) the `WasmBuffer` that will receive the outputs + const outputsLengthInElements = params.getOutputsLength() + if (this.outputsBuffer === undefined || this.outputsBuffer.numElements < outputsLengthInElements) { + this.outputsBuffer?.dispose() + this.outputsBuffer = createFloat64WasmBuffer(this.wasmModule, outputsLengthInElements) + } + + // Run the model + const t0 = perfNow() + this.wasmRunModel( + this.inputsBuffer.getAddress(), + this.outputsBuffer.getAddress(), + outputIndicesBuffer?.getAddress() || 0 + ) + const elapsed = perfElapsed(t0) + + // Copy the outputs that were stored into the `WasmBuffer` back to the `RunModelParams` + params.storeOutputs(this.outputsBuffer.getArrayView()) + + // Store the elapsed time in the `RunModelParams` + params.storeElapsedTime(elapsed) + } + + // from RunnableModel interface + terminate(): void { + this.inputsBuffer?.dispose() + this.inputsBuffer = undefined + + this.outputsBuffer?.dispose() + this.outputsBuffer = undefined + + this.outputIndicesBuffer?.dispose() + this.outputIndicesBuffer = undefined + + // TODO: Dispose the `WasmModule` too? } } +/** + * Initialize the wasm model. + * + * @hidden This is the new replacement for `createWasmModelRunner`; this will be + * exposed (and the old one deprecated) in a separate set of changes. + * + * @param wasmModule The `WasmModule` that wraps the `wasm` binary. + * @param outputVarIds The output variable IDs, per the spec file passed to `sde`. + * @return The initialized `WasmModel` instance. + */ +export function initWasmModel(wasmModule: WasmModule, outputVarIds: OutputVarId[]): RunnableModel { + return new WasmModel(wasmModule, outputVarIds) +} + /** * The result of model initialization. */ export interface WasmModelInitResult { /** The wasm model. */ model: WasmModel - /** The buffer used to pass input values to the model. */ - inputsBuffer: WasmBuffer - /** The buffer used to receive output values from the model. */ - outputsBuffer: WasmBuffer - /** - * The buffer used to control which variables are written to `outputsBuffer`. - * @hidden This is not yet part of the public API; it is exposed here for use - * in experimental testing tools. - */ - outputIndicesBuffer?: WasmBuffer + /** The number of input variables. */ + numInputs: number /** The output variable IDs. */ outputVarIds: OutputVarId[] } @@ -111,51 +155,10 @@ export function initWasmModelAndBuffers( numInputs: number, outputVarIds: OutputVarId[] ): WasmModelInitResult { - // Wrap the native C `runModelWithBuffers` function in a JS function that we can call - const model = new WasmModel(wasmModule) - - // Allocate a buffer that is large enough to hold the input values - const inputsBuffer = createFloat64WasmBuffer(wasmModule, numInputs) - - // Allocate a buffer that is large enough to hold the series data for each output variable - const outputVarCount = Math.max(outputVarIds.length, model.maxOutputIndices) - const outputsBuffer = createFloat64WasmBuffer(wasmModule, outputVarCount * model.numSavePoints) - - // Allocate a buffer for the output indices, if requested (for accessing internal variables) - let outputIndicesBuffer: WasmBuffer - if (model.maxOutputIndices > 0) { - outputIndicesBuffer = createInt32WasmBuffer(wasmModule, model.maxOutputIndices * indicesPerOutput) - } - + const model = new WasmModel(wasmModule, outputVarIds) return { model, - inputsBuffer, - outputsBuffer, - outputIndicesBuffer, + numInputs, outputVarIds } } - -/** - * @hidden This is not part of the public API; it is exposed here for use by - * the synchronous and asynchronous model runner implementations. - */ -export function updateOutputIndices(indicesArray: Int32Array, outputVarSpecs: OutputVarSpec[]): void { - if (indicesArray.length < outputVarSpecs.length * indicesPerOutput) { - throw new Error('Length of indicesArray must be large enough to accommodate the given outputVarSpecs') - } - - // Write the indices to the buffer - let offset = 0 - for (const outputVarSpec of outputVarSpecs) { - const subCount = outputVarSpec.subscriptIndices?.length || 0 - indicesArray[offset + 0] = outputVarSpec.varIndex - indicesArray[offset + 1] = subCount > 0 ? outputVarSpec.subscriptIndices[0] : 0 - indicesArray[offset + 2] = subCount > 1 ? outputVarSpec.subscriptIndices[1] : 0 - indicesArray[offset + 3] = subCount > 2 ? outputVarSpec.subscriptIndices[2] : 0 - offset += indicesPerOutput - } - - // Fill the remainder of the buffer with zeros - indicesArray.fill(0, offset) -}