I've been going down a bit of a rabbit hole trying to figure out the most optimal way to design a bytecode instruction class.
To be clear an instruction is something that holds an opcode and then a differing amount of operand values depending on the type.
After looking at a few open source projects i've noticed 2 primary type of designs:
The common polymorphic approach:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
struct Operands
{
virtual size_t GetNOperands() = 0;
//U32 as the largest type of all operands (there are no S32 or above operands)
virtual U32 GetOperand(size_t index) = 0;
virtual void SetOperand(size_t index, U32 value) = 0;
};
struct NoOperands : public Operands { /*impl Operands interface*/; };
struct PushInstrOperands : public Operands { /*impl Operands interface*/; U8 pushVal1; U8 pushVal2; };
struct RetInstrOperands : public Operands { /*impl Operands interface*/; U16 retValue; };
struct Instr
{
U8 OpCode;
Operands* Ops;
//possibly include a constructor that ensures the correct operand type with OpCode
};
std::vector<Instr> code =
{
Instr{NOP, new NoOperands{}},
Instr{PUSH, new PushInstrOperands{43, 43}},
Instr{RET, new RetInstrOperands{0}},
};
|
The union with encoded format strings approach:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
union OperandType : char
{
U8 = 'B',
U16 = 'S'
U32 = 'I';
};
static const char* InstrFormats[_N_OPCODES];
InstrFormats[NOP] = "";
InstrFormats[PUSH] = "BB";
InstrFormats[RET] = "S";
struct Instr
{
U8 OpCode;
union
{
U8 StackOperands[ sizeof(void*) + 3 ]; // +3 because padding caused from the unaligned U8 OpCode
void* HeapOperands;
};
size_t GetNOperands()
{
return strlen( InstrFormas[OpCode] );
}
//As an optimization any operands that takes up less than sizeof(void*) + 3 bytes can be stored in the stack array
U32 GetOperand(size_t index) { /*...*/ }
void SetOperand(size_t index, U32 value) { /*...*/ }
//include templated constructor that asserts the arguments match and fit in the operands as specified in InstrFormats[OpCode]
};
std::vector<Instr> code =
{
Instr{NOP},
Instr{PUSH, 24, 73},
Instr{RET, 0},
};
|
Initially i believed the second approach was better because it doesn't do virtual table lookups on every API call (GetNOperands, GetOperand, SetOperan, etc.). However then i realized after all it still has to lookup the c string pointer to check the format. But then again, the format strings are going to be tightly packed in the bss section so its likely many (all?) formats will get included in a single cache line, whereas the virtual functions are longer (since they actually contain sequences of code) and probably further apart in the code section, right?
But then i've also heard many CPUs have optimizations for virtual function calls, im not sure what that involves exactly.
Also the union approach stores small operand types on the stack which is better, right? Although in order to access them you have to dereference the format string, which is equivalent of dereferencing the base pointer string which will cache the child types members so i guess its really the same thing. Unless of course the user just wants to access the operand bytes directly and doesn't have to check the format of them.