@daneren2005/shared-memory-objects 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/package.json +3 -2
- package/src/main.ts +8 -6
- package/src/memory-buffer.ts +1 -1
- package/src/memory-heap.ts +50 -21
- package/src/shared-list.ts +7 -0
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Shared Memory Objects
|
|
2
2
|
A library to try to make making a multi-threaded game in Javascript possible. This package is to provide a wrapper to create objects and data structures that are backed by a SharedArrayBuffer and can be shared between multiple threads. The end result is a package that has all of the slowness of Javascript with all of the baggage of dealing with manual memory allocations. If you need to multi-thread you are probably better of just using a different language and compiling to WebAssembly. But if you, like me, just want to use Javascript/Typescript and are willing to deal with dealing with manual memory allocations then this library could save you some time.
|
|
3
3
|
|
|
4
|
+
A demo can be found at https://daneren2005.github.io/ecs-sharedarraybuffer-playground/#/shared-memory-objects
|
|
5
|
+
The code is at https://github.com/daneren2005/ecs-sharedarraybuffer-playground/tree/dev/src/shared-memory-objects
|
|
6
|
+
|
|
4
7
|
## Basics
|
|
5
8
|
The core of this package is the MemoryHeap. You should usually just have a single heap that is shared between all of your different threads. Each heap can have multiple MemoryBuffers. By default each buffer is only 8KB but it can be configured up to 1MB, and you can have up to 4k buffers for a total of 4GB. When you allocate memory, if there is not enough space it will allocate another buffers automatically. When allocating memory, you will get a AllocatedMemory object that is a wrapper around the allocated memory by calling `heap.allocUI32({count of 32 bit numbers})`. By default AllocatedMemory is backed by a Uint32Array but you can get any type of array from `AllocatedMemory.getArray(Int32Array);`.
|
|
6
9
|
|
|
@@ -8,6 +11,50 @@ Each allocated memory location can be stored as an int pointer. You can use `ge
|
|
|
8
11
|
|
|
9
12
|
When passing memory to another thread you can either pass a pointer or a serialized version of the buffer position/byte offset in order to re-create the object in the other thread.
|
|
10
13
|
|
|
14
|
+
## Getting Started
|
|
15
|
+
`npm install @daneren2005/shared-memory-objects`
|
|
16
|
+
|
|
17
|
+
Example to update blocks of memory from a thread.
|
|
18
|
+
```
|
|
19
|
+
let heap = new MemoryHeap();
|
|
20
|
+
let memory = heap.allocUI32(4);
|
|
21
|
+
|
|
22
|
+
// Pass memory to another thread
|
|
23
|
+
thread.postMessage({
|
|
24
|
+
heap: heap.getSharedMemory(),
|
|
25
|
+
memory: memory.getSharedMemory()
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// From worker thread re-construct memory and change it
|
|
29
|
+
self.onmessage = (e) => {
|
|
30
|
+
let heap = new MemoryHeap(e.data.heap);
|
|
31
|
+
let memory = new AllocatedMemory(heap, e.data.memory);
|
|
32
|
+
memory.data[2] = 5;
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
// Example to work with data structures from a thread. When constructing a new structure you just pass the heap. When re-creating a structure from an already initialized memory location pass the heap and the shared memory location for it.
|
|
37
|
+
```
|
|
38
|
+
let heap = new MemoryHeap();
|
|
39
|
+
let list = new SharedList(heap);
|
|
40
|
+
|
|
41
|
+
// Pass memory to another thread
|
|
42
|
+
thread.postMessage({
|
|
43
|
+
heap: heap.getSharedMemory(),
|
|
44
|
+
list: list.getSharedMemory()
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// From worker thread re-construct memory and change it
|
|
48
|
+
self.onmessage = (e) => {
|
|
49
|
+
let heap = new MemoryHeap(e.data.heap);
|
|
50
|
+
let list = new SharedList(heap, e.data.list);
|
|
51
|
+
|
|
52
|
+
list.push(5);
|
|
53
|
+
};
|
|
54
|
+
```
|
|
55
|
+
let mainList = new SharedList(memory);
|
|
56
|
+
let secondList = new SharedList(memory, mainList.getSharedMemory());
|
|
57
|
+
|
|
11
58
|
## Data Structures
|
|
12
59
|
- SharedList
|
|
13
60
|
- SharedVector
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daneren2005/shared-memory-objects",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"author": "daneren2005@gmail.com",
|
|
5
5
|
"description": "Creating objects with a SharedArrayBuffer",
|
|
6
6
|
"homepage": "https://github.com/daneren2005/shared-memory-objects#readme",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"test:ui": "vitest --ui --api 9527",
|
|
35
35
|
"test:unit": "vitest run",
|
|
36
36
|
"test:unit:watch": "vitest",
|
|
37
|
-
"type-check": "tsc --build --force"
|
|
37
|
+
"type-check": "tsc --build --force",
|
|
38
|
+
"prepare": "husky"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@rushstack/eslint-patch": "^1.10.1",
|
package/src/main.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import AllocatedMemory, { type SharedAllocatedMemory } from './allocated-memory';
|
|
2
2
|
import MemoryBuffer from './memory-buffer';
|
|
3
|
-
import MemoryHeap, { type MemoryHeapMemory } from './memory-heap';
|
|
3
|
+
import MemoryHeap, { type MemoryHeapMemory, type GrowBufferData } from './memory-heap';
|
|
4
4
|
|
|
5
5
|
import SharedList, { type SharedListMemory } from './shared-list';
|
|
6
|
-
import SharedMap from './shared-map';
|
|
6
|
+
import SharedMap, { type SharedMapMemory } from './shared-map';
|
|
7
7
|
import SharedPointerList from './shared-pointer-list';
|
|
8
8
|
import SharedString from './shared-string';
|
|
9
|
-
import SharedVector from './shared-vector';
|
|
9
|
+
import SharedVector, { type SharedVectorMemory } from './shared-vector';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
export * from './interfaces/typed-array';
|
|
12
|
+
export * from './interfaces/typed-array-constructor';
|
|
12
13
|
|
|
13
14
|
export * from './utils/16-from-32-array';
|
|
14
15
|
export * from './utils/16-from-64-array';
|
|
@@ -23,13 +24,14 @@ export {
|
|
|
23
24
|
MemoryBuffer,
|
|
24
25
|
MemoryHeap,
|
|
25
26
|
type MemoryHeapMemory,
|
|
27
|
+
type GrowBufferData,
|
|
26
28
|
|
|
27
29
|
SharedList,
|
|
28
30
|
type SharedListMemory,
|
|
29
31
|
SharedMap,
|
|
32
|
+
type SharedMapMemory,
|
|
30
33
|
SharedPointerList,
|
|
31
34
|
SharedString,
|
|
32
35
|
SharedVector,
|
|
33
|
-
|
|
34
|
-
type TypedArrayConstructor
|
|
36
|
+
type SharedVectorMemory
|
|
35
37
|
};
|
package/src/memory-buffer.ts
CHANGED
package/src/memory-heap.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import AllocatedMemory from './allocated-memory';
|
|
1
|
+
import AllocatedMemory, { type SharedAllocatedMemory } from './allocated-memory';
|
|
2
2
|
import prettyBytes from 'pretty-bytes';
|
|
3
3
|
import { MAX_BYTE_OFFSET_LENGTH, MAX_POSITION_LENGTH } from './utils/pointer';
|
|
4
4
|
import MemoryBuffer from './memory-buffer';
|
|
5
5
|
|
|
6
|
-
// TODO: Once we are certain this behaves correctly we should probably up to something like 1MB - we will have a ton of entities so don't want to waste time allocating new buffers constantly
|
|
7
6
|
const DEFAULT_BUFFER_SIZE = 8_192;
|
|
7
|
+
const BUFFER_SIZE_INDEX = 0;
|
|
8
|
+
const BUFFER_COUNT_INDEX = 1;
|
|
8
9
|
export default class MemoryHeap {
|
|
9
10
|
buffers: Array<MemoryBuffer>;
|
|
10
|
-
onGrowBufferHandlers: Array<OnGrowBuffer> = [];
|
|
11
|
+
private onGrowBufferHandlers: Array<OnGrowBuffer> = [];
|
|
11
12
|
isClone: boolean;
|
|
12
13
|
private memory: AllocatedMemory;
|
|
13
14
|
|
|
14
15
|
get bufferSize() {
|
|
15
|
-
return this.memory.data[
|
|
16
|
+
return this.memory.data[BUFFER_SIZE_INDEX];
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
constructor(config?: MemoryHeapConfig | MemoryHeapMemory) {
|
|
@@ -40,7 +41,7 @@ export default class MemoryHeap {
|
|
|
40
41
|
this.buffers = [
|
|
41
42
|
startBuffer
|
|
42
43
|
];
|
|
43
|
-
const data = startBuffer.callocAs('u32',
|
|
44
|
+
const data = startBuffer.callocAs('u32', 2);
|
|
44
45
|
if(data) {
|
|
45
46
|
this.memory = new AllocatedMemory(this, {
|
|
46
47
|
bufferPosition: 0,
|
|
@@ -49,7 +50,8 @@ export default class MemoryHeap {
|
|
|
49
50
|
} else {
|
|
50
51
|
throw new Error('Failed to initialize first byte from buffer');
|
|
51
52
|
}
|
|
52
|
-
this.memory.data[
|
|
53
|
+
this.memory.data[BUFFER_SIZE_INDEX] = bufferSize;
|
|
54
|
+
this.memory.data[BUFFER_COUNT_INDEX] = 1;
|
|
53
55
|
this.isClone = false;
|
|
54
56
|
|
|
55
57
|
for(let i = 1; i < (config?.initialBuffers ?? 1); i++) {
|
|
@@ -58,28 +60,37 @@ export default class MemoryHeap {
|
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
addSharedBuffer(
|
|
62
|
-
this.buffers.
|
|
63
|
-
buf: buffer,
|
|
63
|
+
addSharedBuffer(data: GrowBufferData) {
|
|
64
|
+
this.buffers[data.bufferPosition] = new MemoryBuffer({
|
|
65
|
+
buf: data.buffer,
|
|
64
66
|
skipInitialization: true
|
|
65
|
-
})
|
|
67
|
+
});
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
private createBuffer(bufferSize?: number): MemoryBuffer {
|
|
69
|
-
if(this.isClone) {
|
|
70
|
-
throw new Error('Creating new buffer from worker threads not currently supported');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// TODO: Look into if we should turn off splitting - I think memory is going to get fragmented really quick if we free an Entity with 100 bytes and re-allocate a new one with 80 bytes and just lose the rest
|
|
74
|
-
// As we add stuff like ListMemory that does tons of small allocations that might be fine since they can fill in any small space we have
|
|
75
71
|
return new MemoryBuffer({
|
|
76
|
-
buf: new SharedArrayBuffer(bufferSize ?? this.bufferSize)
|
|
72
|
+
buf: new SharedArrayBuffer(bufferSize ?? this.bufferSize),
|
|
73
|
+
|
|
74
|
+
// We can't use this unless we can 100% guarantee that every thread will stop using memory the instant it is freed
|
|
75
|
+
// ex: Allocate 16 bytes. Thread A frees that allocation and then allocates 12 bytes and 4 bytes, but Thread B is mid-execution on the old allocation can changes the internal state of the 4-byte allocation breaking everything
|
|
76
|
+
// After the internal state is wrong MemoryBuffer will loose track of which blocks are where and how big they are
|
|
77
|
+
compact: false,
|
|
78
|
+
split: false
|
|
77
79
|
});
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
addOnGrowBufferHandlers(handler: OnGrowBuffer) {
|
|
83
|
+
this.onGrowBufferHandlers.push(handler);
|
|
84
|
+
}
|
|
85
|
+
|
|
80
86
|
allocUI32(count: number): AllocatedMemory {
|
|
81
87
|
for(let i = 0; i < this.buffers.length; i++) {
|
|
82
88
|
const buffer = this.buffers[i];
|
|
89
|
+
// Should just mean we haven't synced this buffer from another thread yet
|
|
90
|
+
if(!buffer) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
// Should be fine to initialize all values as 0s since unsigned/signed ints and floats all store 0 as all 0s
|
|
84
95
|
const data = buffer.callocAs('u32', count);
|
|
85
96
|
if(data) {
|
|
@@ -96,8 +107,13 @@ export default class MemoryHeap {
|
|
|
96
107
|
|
|
97
108
|
// If we get here we need to grow another buffer to continue allocating new memory
|
|
98
109
|
const buffer = this.createBuffer();
|
|
99
|
-
this.
|
|
100
|
-
|
|
110
|
+
let nextBufferPosition = Atomics.add(this.memory.data, BUFFER_COUNT_INDEX, 1);
|
|
111
|
+
// Setting index set by internal Atomic count so we can create new buffers from multiple threads and keep position consistent
|
|
112
|
+
this.buffers[nextBufferPosition] = buffer;
|
|
113
|
+
this.onGrowBufferHandlers.forEach(handler => handler({
|
|
114
|
+
bufferPosition: nextBufferPosition,
|
|
115
|
+
buffer: buffer.buf as SharedArrayBuffer
|
|
116
|
+
}));
|
|
101
117
|
|
|
102
118
|
const data = buffer.callocAs('u32', count);
|
|
103
119
|
if(data) {
|
|
@@ -110,6 +126,15 @@ export default class MemoryHeap {
|
|
|
110
126
|
}
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
getSharedAlloc(shared: SharedAllocatedMemory): AllocatedMemory | undefined {
|
|
130
|
+
// Should just mean it hasn't synced to this thread yet
|
|
131
|
+
if(this.buffers[shared.bufferPosition] === undefined) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return new AllocatedMemory(this, shared);
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
get currentUsed() {
|
|
114
139
|
return this.totalAllocated - this.buffers.reduce((total, memPool) => total + memPool.stats().available, 0);
|
|
115
140
|
}
|
|
@@ -136,7 +161,11 @@ function myPrettyBytes(bytes: number) {
|
|
|
136
161
|
});
|
|
137
162
|
}
|
|
138
163
|
|
|
139
|
-
type OnGrowBuffer = (newBuffer:
|
|
164
|
+
type OnGrowBuffer = (newBuffer: GrowBufferData) => void;
|
|
165
|
+
interface GrowBufferData {
|
|
166
|
+
bufferPosition: number
|
|
167
|
+
buffer: SharedArrayBuffer
|
|
168
|
+
}
|
|
140
169
|
|
|
141
170
|
interface MemoryHeapConfig {
|
|
142
171
|
bufferSize?: number
|
|
@@ -146,4 +175,4 @@ interface MemoryHeapMemory {
|
|
|
146
175
|
buffers: Array<SharedArrayBuffer>
|
|
147
176
|
}
|
|
148
177
|
|
|
149
|
-
export type { MemoryHeapConfig, MemoryHeapMemory };
|
|
178
|
+
export type { MemoryHeapConfig, MemoryHeapMemory, GrowBufferData };
|
package/src/shared-list.ts
CHANGED
|
@@ -62,6 +62,7 @@ export default class SharedList<T extends Uint32Array | Int32Array | Float32Arra
|
|
|
62
62
|
this.memory = memory;
|
|
63
63
|
|
|
64
64
|
if(config && 'firstBlock' in config) {
|
|
65
|
+
// TODO: How to handle referencing memory we don't have access to yet because buffer not synced from worker?
|
|
65
66
|
this.firstBlock = new AllocatedMemory(memory, config.firstBlock);
|
|
66
67
|
this.uint16Array = new Uint16Array(this.firstBlock.data.buffer, this.firstBlock.bufferByteOffset + (LENGTH_INDEX + 1) * Uint32Array.BYTES_PER_ELEMENT, 2);
|
|
67
68
|
} else {
|
|
@@ -119,6 +120,7 @@ export default class SharedList<T extends Uint32Array | Int32Array | Float32Arra
|
|
|
119
120
|
|
|
120
121
|
if(lastBlockPointer) {
|
|
121
122
|
let { bufferPosition: lastBlockPosition, bufferByteOffset: lastBlockByteOffset } = getPointer(lastBlockPointer);
|
|
123
|
+
// TODO: How to handle referencing memory we don't have access to yet because buffer not synced from worker?
|
|
122
124
|
let lastBlock = new Uint32Array(this.memory.buffers[lastBlockPosition].buf, lastBlockByteOffset, 1);
|
|
123
125
|
storeRawPointer(lastBlock, 0, newBlockPointer);
|
|
124
126
|
} else {
|
|
@@ -175,6 +177,11 @@ export default class SharedList<T extends Uint32Array | Int32Array | Float32Arra
|
|
|
175
177
|
let lastBlockByteOffset = 0;
|
|
176
178
|
while(nextBlockByteOffset) {
|
|
177
179
|
let memPool = this.memory.buffers[nextBlockPosition];
|
|
180
|
+
// Short circuit iterations if we can't access memory
|
|
181
|
+
if(!memPool) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
178
185
|
let blockRecord = new Uint32Array(memPool.buf, nextBlockByteOffset, 2);
|
|
179
186
|
let blockData = this.getDataBlock(blockRecord);
|
|
180
187
|
|