Skip to content

Commit 8e29d75

Browse files
committed
Update to version 0.6.2 of @phughesmcr/bitpool and add zero-allocation mode benchmarks
- Updated the import for @phughesmcr/bitpool to version 0.6.2. - Introduced new benchmarks for zero-allocation mode in SparseFacade, demonstrating performance with bounded entity IDs. - Enhanced Partition and PartitionedBuffer to support maxEntityId for zero-allocation storage, improving memory efficiency in ECS applications. - Updated tests to validate zero-allocation functionality and error handling for entity ID limits.
1 parent 59cba08 commit 8e29d75

10 files changed

+708
-43
lines changed

bench/PartitionedBuffer.bench.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,265 @@ Deno.bench({
11721172
},
11731173
});
11741174

1175+
// =============================================================================
1176+
// ZERO-ALLOCATION MODE BENCHMARKS
1177+
// =============================================================================
1178+
1179+
Deno.bench({
1180+
name: "SparseFacade - Map-based mode (arbitrary entity IDs)",
1181+
fn: () => {
1182+
const buffer = new PartitionedBuffer(1024, 64);
1183+
type Schema = { value: number };
1184+
// Map-based: no maxEntityId specified
1185+
const spec: PartitionSpec<Schema> = {
1186+
name: "mapBased",
1187+
schema: { value: Float32Array },
1188+
maxOwners: 16,
1189+
};
1190+
const partition = buffer.addPartition(new Partition(spec));
1191+
if (!partition) return;
1192+
1193+
// Simulate entity operations with arbitrary IDs
1194+
for (let i = 0; i < 16; i++) {
1195+
partition.partitions.value[i * 1000] = i * 1.5; // Large entity IDs
1196+
}
1197+
for (let i = 0; i < 16; i++) {
1198+
partition.partitions.value[i * 1000]; // Read
1199+
}
1200+
for (let i = 0; i < 8; i++) {
1201+
delete partition.partitions.value[i * 1000]; // Delete half
1202+
}
1203+
},
1204+
});
1205+
1206+
Deno.bench({
1207+
name: "SparseFacade - Zero-allocation mode (bounded entity IDs)",
1208+
fn: () => {
1209+
const buffer = new PartitionedBuffer(1024, 64);
1210+
type Schema = { value: number };
1211+
// Zero-allocation: maxEntityId specified
1212+
const spec: PartitionSpec<Schema> & { maxEntityId: number } = {
1213+
name: "zeroAlloc",
1214+
schema: { value: Float32Array },
1215+
maxOwners: 16,
1216+
maxEntityId: 20000, // Pre-allocated sparse array
1217+
};
1218+
const partition = buffer.addPartition(new Partition(spec));
1219+
if (!partition) return;
1220+
1221+
// Same operations as Map-based for fair comparison
1222+
for (let i = 0; i < 16; i++) {
1223+
partition.partitions.value[i * 1000] = i * 1.5;
1224+
}
1225+
for (let i = 0; i < 16; i++) {
1226+
partition.partitions.value[i * 1000];
1227+
}
1228+
for (let i = 0; i < 8; i++) {
1229+
delete partition.partitions.value[i * 1000];
1230+
}
1231+
},
1232+
});
1233+
1234+
Deno.bench({
1235+
name: "SparseFacade - Zero-allocation high-frequency write",
1236+
fn: () => {
1237+
const buffer = new PartitionedBuffer(2048, 128);
1238+
type Schema = { x: number; y: number };
1239+
const spec: PartitionSpec<Schema> & { maxEntityId: number } = {
1240+
name: "zeroAllocFrequent",
1241+
schema: { x: Float32Array, y: Float32Array },
1242+
maxOwners: 32,
1243+
maxEntityId: 10000,
1244+
};
1245+
const partition = buffer.addPartition(new Partition(spec));
1246+
if (!partition) return;
1247+
1248+
// High-frequency updates (typical game loop pattern)
1249+
for (let frame = 0; frame < 10; frame++) {
1250+
for (let entity = 0; entity < 32; entity++) {
1251+
const entityId = entity * 100;
1252+
partition.partitions.x[entityId] = Math.sin(frame + entity);
1253+
partition.partitions.y[entityId] = Math.cos(frame + entity);
1254+
}
1255+
}
1256+
},
1257+
});
1258+
1259+
Deno.bench({
1260+
name: "SparseFacade - Map-based high-frequency write",
1261+
fn: () => {
1262+
const buffer = new PartitionedBuffer(2048, 128);
1263+
type Schema = { x: number; y: number };
1264+
const spec: PartitionSpec<Schema> = {
1265+
name: "mapBasedFrequent",
1266+
schema: { x: Float32Array, y: Float32Array },
1267+
maxOwners: 32,
1268+
// No maxEntityId - uses Map
1269+
};
1270+
const partition = buffer.addPartition(new Partition(spec));
1271+
if (!partition) return;
1272+
1273+
// Same pattern for comparison
1274+
for (let frame = 0; frame < 10; frame++) {
1275+
for (let entity = 0; entity < 32; entity++) {
1276+
const entityId = entity * 100;
1277+
partition.partitions.x[entityId] = Math.sin(frame + entity);
1278+
partition.partitions.y[entityId] = Math.cos(frame + entity);
1279+
}
1280+
}
1281+
},
1282+
});
1283+
1284+
Deno.bench({
1285+
name: "SparseFacade - Zero-allocation entity churn",
1286+
fn: () => {
1287+
const buffer = new PartitionedBuffer(1024, 64);
1288+
type Schema = { value: number };
1289+
const spec: PartitionSpec<Schema> & { maxEntityId: number } = {
1290+
name: "churnZeroAlloc",
1291+
schema: { value: Int32Array },
1292+
maxOwners: 8,
1293+
maxEntityId: 1000,
1294+
};
1295+
const partition = buffer.addPartition(new Partition(spec));
1296+
if (!partition) return;
1297+
1298+
// Simulate entity creation/destruction churn
1299+
for (let cycle = 0; cycle < 5; cycle++) {
1300+
// Create entities
1301+
for (let i = 0; i < 8; i++) {
1302+
partition.partitions.value[cycle * 100 + i] = i;
1303+
}
1304+
// Destroy entities
1305+
for (let i = 0; i < 8; i++) {
1306+
delete partition.partitions.value[cycle * 100 + i];
1307+
}
1308+
}
1309+
},
1310+
});
1311+
1312+
Deno.bench({
1313+
name: "SparseFacade - Map-based entity churn",
1314+
fn: () => {
1315+
const buffer = new PartitionedBuffer(1024, 64);
1316+
type Schema = { value: number };
1317+
const spec: PartitionSpec<Schema> = {
1318+
name: "churnMapBased",
1319+
schema: { value: Int32Array },
1320+
maxOwners: 8,
1321+
// No maxEntityId
1322+
};
1323+
const partition = buffer.addPartition(new Partition(spec));
1324+
if (!partition) return;
1325+
1326+
// Same churn pattern
1327+
for (let cycle = 0; cycle < 5; cycle++) {
1328+
for (let i = 0; i < 8; i++) {
1329+
partition.partitions.value[cycle * 100 + i] = i;
1330+
}
1331+
for (let i = 0; i < 8; i++) {
1332+
delete partition.partitions.value[cycle * 100 + i];
1333+
}
1334+
}
1335+
},
1336+
});
1337+
1338+
Deno.bench({
1339+
name: "SparseFacade - Zero-allocation read-heavy workload",
1340+
fn: () => {
1341+
const buffer = new PartitionedBuffer(1024, 64);
1342+
type Schema = { value: number };
1343+
const spec: PartitionSpec<Schema> & { maxEntityId: number } = {
1344+
name: "readHeavyZeroAlloc",
1345+
schema: { value: Float32Array },
1346+
maxOwners: 16,
1347+
maxEntityId: 2000,
1348+
};
1349+
const partition = buffer.addPartition(new Partition(spec));
1350+
if (!partition) return;
1351+
1352+
// Setup
1353+
for (let i = 0; i < 16; i++) {
1354+
partition.partitions.value[i * 100] = i;
1355+
}
1356+
1357+
// Heavy reads (common in render systems)
1358+
let sum = 0;
1359+
for (let frame = 0; frame < 100; frame++) {
1360+
for (let i = 0; i < 16; i++) {
1361+
sum += partition.partitions.value[i * 100] ?? 0;
1362+
}
1363+
}
1364+
},
1365+
});
1366+
1367+
Deno.bench({
1368+
name: "SparseFacade - Map-based read-heavy workload",
1369+
fn: () => {
1370+
const buffer = new PartitionedBuffer(1024, 64);
1371+
type Schema = { value: number };
1372+
const spec: PartitionSpec<Schema> = {
1373+
name: "readHeavyMapBased",
1374+
schema: { value: Float32Array },
1375+
maxOwners: 16,
1376+
};
1377+
const partition = buffer.addPartition(new Partition(spec));
1378+
if (!partition) return;
1379+
1380+
// Setup
1381+
for (let i = 0; i < 16; i++) {
1382+
partition.partitions.value[i * 100] = i;
1383+
}
1384+
1385+
// Heavy reads
1386+
let sum = 0;
1387+
for (let frame = 0; frame < 100; frame++) {
1388+
for (let i = 0; i < 16; i++) {
1389+
sum += partition.partitions.value[i * 100] ?? 0;
1390+
}
1391+
}
1392+
},
1393+
});
1394+
1395+
Deno.bench({
1396+
name: "SparseFacade - Zero-allocation multi-component ECS pattern",
1397+
fn: () => {
1398+
const buffer = new PartitionedBuffer(4096, 256);
1399+
const maxEntityId = 10000;
1400+
1401+
// Typical ECS components with zero-allocation
1402+
type PosSchema = { x: number; y: number; z: number };
1403+
const posSpec: PartitionSpec<PosSchema> & { maxEntityId: number } = {
1404+
name: "position",
1405+
schema: { x: Float32Array, y: Float32Array, z: Float32Array },
1406+
maxOwners: 64,
1407+
maxEntityId,
1408+
};
1409+
1410+
type VelSchema = { x: number; y: number; z: number };
1411+
const velSpec: PartitionSpec<VelSchema> & { maxEntityId: number } = {
1412+
name: "velocity",
1413+
schema: { x: Float32Array, y: Float32Array, z: Float32Array },
1414+
maxOwners: 64,
1415+
maxEntityId,
1416+
};
1417+
1418+
const pos = buffer.addPartition(new Partition(posSpec));
1419+
const vel = buffer.addPartition(new Partition(velSpec));
1420+
if (!pos || !vel) return;
1421+
1422+
// Physics update simulation
1423+
for (let frame = 0; frame < 10; frame++) {
1424+
for (let entity = 0; entity < 64; entity++) {
1425+
const id = entity * 100;
1426+
pos.partitions.x[id]! += vel.partitions.x?.[id] ?? 0;
1427+
pos.partitions.y[id]! += vel.partitions.y?.[id] ?? 0;
1428+
pos.partitions.z[id]! += vel.partitions.z?.[id] ?? 0;
1429+
}
1430+
}
1431+
},
1432+
});
1433+
11751434
Deno.bench({
11761435
name: "PartitionedBuffer - Memory pressure scenario",
11771436
fn: () => {

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"exclude": ["*.md", "LICENSE"]
3434
},
3535
"imports": {
36-
"@phughesmcr/bitpool": "jsr:@phughesmcr/bitpool@^0.6.1"
36+
"@phughesmcr/bitpool": "jsr:@phughesmcr/bitpool@^0.6.2"
3737
},
3838
"tasks": {
3939
"example": {

src/Partition.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@ export type PartitionMeta<T extends SchemaSpec<T> | null> = {
1919
*/
2020
maxOwners?: number | null;
2121

22+
/**
23+
* Maximum entity ID for zero-allocation sparse storage (inclusive).
24+
*
25+
* When specified with `maxOwners`, enables zero-allocation mode using
26+
* pre-allocated Int32Arrays instead of a Map for the sparse mapping.
27+
*
28+
* Entity IDs must be in range [0, maxEntityId] when this is set.
29+
* If not specified, the Map-based implementation is used which supports
30+
* arbitrary entity IDs but allocates on first insertion of each new entity.
31+
*
32+
* __Recommended__: Set this to your world's maximum entity count for
33+
* optimal performance in ECS applications.
34+
*
35+
* @example ```
36+
* // Zero-allocation mode: entity IDs 0-9999
37+
* const spec: PartitionSpec<Vec2> = {
38+
* name: "position",
39+
* schema: { x: Float32Array, y: Float32Array },
40+
* maxOwners: 100, // Only 100 entities can have this component
41+
* maxEntityId: 9999, // Entity IDs are bounded by world size
42+
* };
43+
* ```
44+
*/
45+
maxEntityId?: number | null;
46+
2247
/** The component's label */
2348
name: string;
2449
};
@@ -69,11 +94,12 @@ export type PartitionStorage<T extends SchemaSpec<T> | null> = T extends SchemaS
6994
* @returns `true` if the specification is valid
7095
*/
7196
export function isValidPartitionSpec<T extends SchemaSpec<T> | null>(spec: unknown): spec is PartitionSpec<T> {
72-
const { name, schema = null, maxOwners = null } = spec as PartitionSpec<T>;
97+
const { name, schema = null, maxOwners = null, maxEntityId = null } = spec as PartitionSpec<T> & {
98+
maxEntityId?: number | null;
99+
};
73100
if (!isValidName(name)) return false;
74-
if (maxOwners !== null && (!Number.isSafeInteger(maxOwners) || maxOwners <= 0)) {
75-
throw new Error("maxOwners must be a positive integer or null");
76-
}
101+
if (maxOwners !== null && (!Number.isSafeInteger(maxOwners) || maxOwners <= 0)) return false;
102+
if (maxEntityId !== null && (!Number.isSafeInteger(maxEntityId) || maxEntityId < 0)) return false;
77103
if (schema && !isSchema(schema)) return false;
78104
return true;
79105
}
@@ -86,6 +112,8 @@ export class Partition<T extends SchemaSpec<T> | null = null> {
86112
readonly schema: T extends SchemaSpec<infer U> ? Schema<U> : null;
87113
/** The maximum number of entities able to equip this component per instance. */
88114
readonly maxOwners: number | null;
115+
/** Maximum entity ID for zero-allocation sparse storage (inclusive). */
116+
readonly maxEntityId: number | null;
89117
/** The storage requirements of the schema in bytes for a single entity */
90118
readonly size: number;
91119
/** `true` if the partition is a tag */
@@ -100,10 +128,13 @@ export class Partition<T extends SchemaSpec<T> | null = null> {
100128
if (!isValidPartitionSpec(spec)) {
101129
throw new SyntaxError("Invalid partition specification.");
102130
}
103-
const { name, schema = null, maxOwners = null } = spec;
131+
const { name, schema = null, maxOwners = null, maxEntityId = null } = spec as PartitionSpec<T> & {
132+
maxEntityId?: number | null;
133+
};
104134
this.name = name;
105135
this.schema = schema as T extends SchemaSpec<infer U> ? Schema<U> : null;
106136
this.maxOwners = maxOwners ?? null;
137+
this.maxEntityId = maxEntityId ?? null;
107138
this.size = schema ? getEntitySize(schema) : 0;
108139
this.isTag = (schema === null) as T extends null ? true : false;
109140
}

0 commit comments

Comments
 (0)