1

This is a performance-related question. As such, regular JS rules and conventions are irrelevant here. Note also that JS objects are not in any way performance-equivalent to C structs, particularly those allocated in AoS format. Thank you for your understanding.

In C, we read or write large structs' data fast by pulling a struct instance from an array of struct into a local variable, modifying it, and writing that (as a value) directly back into the array again by simple assignment. The struct can be up to some platform-specified limit, usually up to several thousand bytes. This makes for both easy and super-fast handling of data. Disassembly may show some fast memcpy type stuff happening under the hood to achieve this. C example:

struct MyStruct arrayOfStructs[]; //on stack for simplicity
//...populate the Array-of-Struct with some data...

//copy out the struct we want, onto the stack:
struct MyStruct structValue = arrayOfStructs[i];

//...change the structValue's members
structValue.x = 12;
structValue.y = 3;

//assign it back by value, rather than memberwise.
arrayOfStructs[i] = structValue;

The benefit here is that no matter how large the struct is, we write it back in a single line, and the compiler handles the copying needed to make that happen as quickly as possible.

In Javascript, the only performant alternative is to use (Typed)Arrays. We fake our "struct" thus:

const SIZEOF_X = 1; //1 byte
const SIZEOF_Y = 1; //1 byte
const SIZEOF_Z = 1; //1 byte
const SIZEOF_PADDING0 = 1; //1 byte padding, aligns following members to 4B heap boundaries.
const SIZEOF_PAYLOAD1 = 2; //2 bytes
const SIZEOF_PAYLOAD2 = 2; //2 bytes

const SIZEOF_BYTES_ENTITY = 
                      SIZEOF_X +
                      SIZEOF_Y + 
                      SIZEOF_Z + 
                      SIZEOF_PADDING0 + 
                      SIZEOF_PAYLOAD1 +
                      SIZEOF_PAYLOAD2; //8 bytes total.

//calculating offsets into the "struct" can be done using a loop, but for clarity:
const OFFSET_BYTES_X = 0;
const OFFSET_BYTES_Y = SIZEOF_BYTES_X;
const OFFSET_BYTES_Z = SIZEOF_BYTES_X + SIZEOF_BYTES_Y;
//...etc.

const buffer = new ArrayBuffer(ENTITIES_COUNT * SIZEOF_BYTES_ENTITY);
const structsAs64 = new Uint64Array(buffer); //Uint64 because one struct = 8B.
const structsAs16 = new Uint16Array(buffer);
const structsAs8  = new  Uint8Array(buffer);
//...populate our underlying buffer with some data... (not shown)

const structValue64 = structsAs64[i]; 
const z = structValue64 >> OFFSET_BYTES_Z;
//OR use a finer-grained view over the same data:
const z = structsAs8[i * SIZEOF_BYTES_ENTITY + OFFSET_BYTES_Z];

Great, it works. But what if our fake structs are greater than 64 bits (8 bytes) in length?

//now the new struct type we want to pull is 12x larger!
const SIZEOF_BYTES_ENTITY = 96;
const SIZEOF_WORDS_ENTITY = SIZEOF_BYTES_ENTITY / 8; //multiple 64-bit / 8-byte words.

const buffer = new ArrayBuffer(ENTITIES_COUNT * SIZEOF_BYTES_ENTITY);
const bigStructsAs64 = new Uint64Array(buffer);
//...populate our underlying buffer with some data... (not shown)

let index = 43; //to the entity we want

let bigStruct = bigStructsAs64.subarray(index * SIZEOF_ENTITY_WORDS);

.subarray() itself is a no-no as it creates a new TypedArray view which must then be GC'ed (possibly every animation frame). But at least we can be sure we are not doing this in JS code:

const bigStruct = new Uint64Array(SIZEOF_ENTITY_WORDS);

//copy out, one 64-bit word at a time:
for (int w = 0; w < SIZEOF_ENTITY_WORDS; w++)
{
    bigStruct[w] = bigStructsAs64[index * SIZEOF_ENTITY_WORDS + w];
}

Is there any faster method for pulling back large datablocks without using explicit JS loops?


Additional detail This question seems relevant in terms of overestimating the potential impact of the loop mentioned above.

My assumption thus far has been that native methods would perform this data-gathering operation more efficiently than loops written in JS. I'll need to run some tests and get back with results (pending).

16
  • 1
    May I ask if you considered using WASM instead? Commented Jun 11, 2024 at 23:28
  • @Dai That is on the cards, yes. But I would still like to know what is possible under JS directly, without involving C WASM modules for the time being. Commented Jun 11, 2024 at 23:29
  • 2
    "The benefit here is that no matter how large the struct is, we write it back in a single line, and the compiler handles the copying needed to make that happen as quickly as possible." - that's great and all, but why are you copying the struct around at all instead of modifying the array in-place? That would be faster even in C. And just because you can "write it back in a single line" doesn't mean that it won't be slow or that its speed won't depend on the size of the struct. Commented Jun 12, 2024 at 1:14
  • 1
    "This is a performance-related question." - in that case, you'll need to post the actual code that you need optimised, and explain where and how and how often it is called. We can't help you with specifics otherwise, and general questions about "how to optimise C-like JavaScript" are way too broad. Also, ericlippert.com/2012/12/17/performance-rant applies Commented Jun 12, 2024 at 1:18
  • 2
    "regular JS rules and conventions are irrelevant here" - not really, as that is what JS engines will optimise for. So this should always be your first approach, and you should then benchmark it and find the bottlenecks and optimise them (or choose better data structures and algorithms). If you want to go so low-level as to optimise for cache lines, you really need to use WASM. Commented Jun 12, 2024 at 1:20

1 Answer 1

2

Is there any faster method for pulling back large datablocks without using explicit JS loops?

If you have used

let bigStruct = bigStructsAs64.subarray(index * SIZEOF_ENTITY_WORDS, (index * SIZEOF_ENTITY_WORDS);

then you don't actually need to "pull it back", since the subarray method only creates another view on the same buffer. Modifying bigStruct directly writes to the underlying buffer of bigStructsAs64.

However if you have used

let bigStruct = bigStructsAs64.slice(index * SIZEOF_ENTITY_WORDS, (index+1) * SIZEOF_ENTITY_WORDS);

or another method to create a separate copy of the struct, then you can use the set method to efficiently copy a whole array into another typed array:

bigStructsAs64.set(bigStruct, index * SIZEOF_ENTITY_WORDS);

If you want to copy only part of a typed array into another type array, you should first get a .subarray(…) with the part that you want to copy over and then .set(…) that:

target.set(source.subarray(sourceStart, sourceStart + length), targetStart);
Sign up to request clarification or add additional context in comments.

2 Comments

Right, and .subarray() allocates which is why it's not preferred. I cannot pre-allocate a separate sub-array for every one of thousands or hundreds of thousands of rays, particles, or entities, unfortunately. .slice() also allocates. In the end we're not getting out of this without allocations. At least with the loop, we avoid those allocations and the very frequent GC sweeps they incur, since we need to do what you describe above many times per frame at 60fps. So we see the difference between a C struct and what JS allows. If loop works, then it's to be preferred over allocs. Else C-WASM.
You asked to do it without a loop, not without allocations :-) I would hope that V8 can optimise away the allocation of the subarray that's only passed to set, and even if it can't, it shouldn't give you much trouble since it will be garbage-collected very efficiently. I recommend to try it anyway - I would expect there's a cutoff at a certain size of the copied array where it's faster to construct an intermediate view and take advantage of the more efficient copying, than doing the individual assignments in a js loop.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.