@daneren2005/shared-memory-objects 0.0.3 → 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 +3 -0
- package/package.json +3 -2
- package/src/main.ts +2 -1
- package/src/memory-buffer.ts +1 -1
- package/src/memory-heap.ts +50 -21
- package/src/shared-list.ts +7 -3
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
|
|
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,6 +1,6 @@
|
|
|
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
6
|
import SharedMap, { type SharedMapMemory } from './shared-map';
|
|
@@ -24,6 +24,7 @@ export {
|
|
|
24
24
|
MemoryBuffer,
|
|
25
25
|
MemoryHeap,
|
|
26
26
|
type MemoryHeapMemory,
|
|
27
|
+
type GrowBufferData,
|
|
27
28
|
|
|
28
29
|
SharedList,
|
|
29
30
|
type SharedListMemory,
|
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,12 +62,12 @@ 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 {
|
|
68
69
|
if(config && config.initWithBlock) {
|
|
69
70
|
this.firstBlock = new AllocatedMemory(memory, config.initWithBlock);
|
|
70
|
-
console.log(`init block at ${this.firstBlock.data.byteOffset}`);
|
|
71
71
|
} else {
|
|
72
72
|
this.firstBlock = memory.allocUI32(FIRST_BLOCK_RECORD_KEEPING_COUNT);
|
|
73
73
|
}
|
|
@@ -120,12 +120,12 @@ export default class SharedList<T extends Uint32Array | Int32Array | Float32Arra
|
|
|
120
120
|
|
|
121
121
|
if(lastBlockPointer) {
|
|
122
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?
|
|
123
124
|
let lastBlock = new Uint32Array(this.memory.buffers[lastBlockPosition].buf, lastBlockByteOffset, 1);
|
|
124
125
|
storeRawPointer(lastBlock, 0, newBlockPointer);
|
|
125
126
|
} else {
|
|
126
127
|
// First item - store on first block
|
|
127
128
|
storeRawPointer(this.firstBlock.data, 0, newBlockPointer);
|
|
128
|
-
console.log(`insert first block ${newBlockPointer} at ${this.firstBlock.data.byteOffset}`);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Always update new last buffer position and length
|
|
@@ -172,12 +172,16 @@ export default class SharedList<T extends Uint32Array | Int32Array | Float32Arra
|
|
|
172
172
|
*[Symbol.iterator]() {
|
|
173
173
|
let currentIndex = 0;
|
|
174
174
|
let { bufferPosition: nextBlockPosition, bufferByteOffset: nextBlockByteOffset } = loadPointer(this.firstBlock.data, 0);
|
|
175
|
-
console.log(`checking first block: ${nextBlockByteOffset} at ${this.firstBlock.data.byteOffset}`);
|
|
176
175
|
let lastBlockData = this.firstBlock.data;
|
|
177
176
|
let lastBlockPosition = 0;
|
|
178
177
|
let lastBlockByteOffset = 0;
|
|
179
178
|
while(nextBlockByteOffset) {
|
|
180
179
|
let memPool = this.memory.buffers[nextBlockPosition];
|
|
180
|
+
// Short circuit iterations if we can't access memory
|
|
181
|
+
if(!memPool) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
181
185
|
let blockRecord = new Uint32Array(memPool.buf, nextBlockByteOffset, 2);
|
|
182
186
|
let blockData = this.getDataBlock(blockRecord);
|
|
183
187
|
|