On 6/5/2025 4:49 AM, BGB wrote:
Ended up partially crossing paths back into this (after a partial break from working on my ISA project).
So, previously, I had JX2VM, which emulates things at the level of the various hardware devices. So, it has a physical address space, and a TLB that operates on top of this to translate addresses, ...
I had considered using JX2VM as an extension VM for the 3D engine I was working on recently, but...
* It is a fairly big chunk of code;
* It uses a lot of RAM.
** Needs to allocate RAM both for the VM's RAM, and a FAT32 ramdisk.
* Would be no easy way to route COM style interfaces into/out-of the VM.
* ...
...
So, looks like to load up Doom, this VM uses (in terms of RAM):
~ 5 MB, for decoded traces and opcodes;
~ 3 MB, for the loaded / decompressed PE image.
~ 30MB, mmaps/etc, for Doom's memory usage.
There is ~ 60 MB that goes into MSVCRT itself and to native OpenGL context creation it seems.
While there is only ~ 300K or so in the ".text" section, unpacking into decoded instructions and traces does make it a fair bit bigger.
At present, each opcode uses ~ 64 bytes, and each trace uses ~ 256.
Traces currently (directly) have space for 24 instructions.
Average trace length: 9 instructions.
Splits:
12: ~ 55 / 45 (nearly break even)
16: ~ 60 / 40 (60% of traces have fewer than 16).
24: ~ 92 / 8 (92% of traces have fewer than 24).
32: ~ 95 / 5.
Statistical distribution appears different from that seen in immediate values or function arguments, in that it is more like a lopsided bell-curve. Peak is ~ 2.4 instructions, then falls off slowly.
Bumping from 24 to 32 doesn't have much benefit for performance, but does noticeably increase memory use. Here, ~ 20-24 seems to be most optimal.
This does leave the ~ 8% that are longer than 24, but had ended up using a hack of allowing the trace to have an "extension" which adds space for another 24 spots (increasing max trace length to 48). Only ~ 0.3% would be longer than 48, which is an acceptable loss.
While hacky, 24 base or 48 extended does seem to perform well (in terms of trace-dispatch handling, there is only a minor cost increase for the extension, which is at least slightly less than the performance cost of using 2 separate traces).
A few stats at present:
VM code size: Currently ~ 11 kLOC;
2.1 kL: PE/PEL loader stuff.
1.7 kL: RV64G decoder.
1.5 kL: XG3 decoder.
0.9 kL: Interp front-end
0.9 kL: ALU instruction handlers
0.8 kL: Trace Handling
0.8 kL: FPU & SIMD instruction handlers
...
Interp performance (MSVC):
/Zi : ~ 60- 80 MIPs
/Zi /O2: ~ 120-140 MIPs
/O2 : ~ 150-170 MIPs
It isn't as small or as fast as I would have hoped, but it is what it is I guess...
Has grown now to ~ 13 kL, most notable changes:
2.2 kL: RV64G decoder (added some more parts of the RV ISA)
1.5 kL: Trace handling (supporting extended traces)
...
Performance:
/Zi : ~ 100 MIPs
/Zi /O2 : ~ 220 MIPs
/O2 : ~ 275 MIPs
Can note that this is one of the faster interpreter designs I am aware of. If there is a faster strategy, I am not really aware of it ATM.
Where, as noted, the general interpreter structure consists of a trace loop, roughly:
while(tr && n--)
{ tr=tr->Run(ctx, tr); }
And, each trace does some initial setup and then runs a sequence of operations:
//setup state for trace
ops=tr->ops;
ctx->tr_next=tr->tr_next;
ctx->tr_bra=tr->tr_bra;
...
//run ops
op=ops[0]; op->Run(ctx, op);
op=ops[1]; op->Run(ctx, op);
op=ops[2]; op->Run(ctx, op);
op=ops[3]; op->Run(ctx, op);
...
return(ctx->tr_next);
Where, besides the trace related functions, one of the high-ranking operations in the profiler is the 3RI ADD handler, which basically looks like:
void X3VM_Opc_ADD_3RI(X3VM_Context *ctx, X3VM_Opcode *op)
{ ctx->reg[op->rn]=ctx->reg[op->rs]+op->imm; }
With an address-translation punch-through hack, was able to do a subset of LDP/SDP operations as, say:
void X3VM_Opc_ST64P_3RI_FSP(X3VM_Context *ctx, X3VM_Opcode *op)
{
u64 *ptr, *reg;
int rs, rn;
reg=ctx->reg; rs=op->rs; rn=op->rn;
ptr=(u64 *)(reg[rs]+op->imm);
ptr[0]=reg[rn+0];
ptr[1]=reg[rn+1];
}
Where, the initial part of the normal (slower) memory-access path looks like:
pga=(u32)(addr>>14);
pba=addr&16383;
h=pga&4095;
if(ctx->tlb_vpgaddr0[h]==pga)
{
pgpb=ctx->tlb_vpgptr0[h];
return((void *)(pgpb+pba));
}
This part takes the brunt of the hot-path, not currently an obvious way to speed it up.
Making and the table either bigger of smaller reduces performance.
Bigger: Makes table access smaller.
Smaller: Increases fall-through.
There are actually 2 such tables, so it works like a 2-way associative cache; with accesses to the second table swapping the pages in the first and second tables. The second table sees a lot less traffic though, and only a small part of the accesses miss both tables (which falls back to a page-table walk).
...
Note that there is no JIT or anything here, just a plain C interpreter. And, seemingly one is hard-pressed to get a plain C interpreter loop spinning fast enough to exceed 200 MIPs.
Seems I have exceeded this slightly...
Didn't think this would happen, but it did...
Though, ATM, doesn't look like I am particularly likely to get much past 300 MIPs though.
Can note, in the profiler, time is mostly going into:
Code for mapping addresses and performing load/store;
The main trampoline loop;
Various commonly used instruction handlers and trace lengths.
I was able to use hacky tweaks to reduce the time spent in the address translation (noting which base registers were used and having the interpreter have knowledge of what type of memory is backing the stack and similar, ...).
While some time does go into the trace-lookup hash, it is comparably small as I added a small link-trace predictor stack for JAL and JALR.
"JAL X1, Disp" pushes a trace onto the stack;
"JALR X0, X1, 0", if the trace is on the stack, it is popped.
In this case, N levels of function returns can be predicted.
Traces will cache links to other traces for static branches, ...
But, even as such, the dispatch loop is a bottleneck. In a past VM, one strategy was to merge traces across unconditional branches.
Done now.
Some merging across JAL and similar did see a speedup.
Some possible more aggressive merging could be considered, such as having the trace-decoder merge across a JALR when it can statically infer the destination.
One other possibility could be "Load/Store" combining.
Say, if the interpreter sees something like:
LD X18, 16(X2)
LD X19, 24(X2)
LD X20, 32(X2)
LD X21, 40(X2)
...
It fuses them into an internal operation that does all 4 loads as a logical combined operation.
In debugging stuff, did make the realization that RISC-V's SLLW instruction, despite being called SLLW (for Shift Logical Left), actually needs to behave like a Shift Left Arithmetic instruction...
Might have been better, say, had they called it 'SLAW' instead.
Well, also likely I could get a speedup in this case with a JIT, but a JIT is still a rather heavyweight solution.
Had noted that a significant portion of the memory load/store traffic is going to the stack. One possible (but heavy handed) optimization here could be to used shared memory mappings for the stacks, and then have special cases for stack-based Load/Store operations which sidestep the VM's normal address translation and similar (though, in this case, the stack could only be used in a certain limited range of ways; possibly with a "stack fault" or similar if the relevant constraints are violated, *2).
...
*2: Stack would be required to be aligned and point to special stack- pointer memory (though probably would just be GlobalAlloc memory).
Using GlobalAlloc memory for both stack and ".data"/".bss", with some special optimization cases for cases where stack/bss are allocated as such, can see a significant reduction in the number of addresses that need to be translated. If the setup code detects that these hold, it can decode instructions in a way gets faster load/store by bypassing the usual address translation.
Can't be extended to general memory access though as this would allow the program free access to the host program's memory. But, SP and GBR are only used in very particular ways, making it practical to detect potential pattern violations (and deal with them somehow).
Could try to get clever, but the "naive sledgehammer solution" being mostly that if SP or GP is modified outside of the defined rules, the VM will flush the whole trace cache and restart decoding with this optimization disabled.
Then again, the interpreter is already fast enough to keep Doom pegged at the framerate limiter, so maybe fast enough for what I want to use it for, even if maybe not particularly fast (TBD if I should try running Quake or similar on it).
Not done just yet.
I don't have Quake set up to build into the correct form as of yet.
Needs to be XG3 or RV compiled to PE/COFF.
Could almost try running the RV64 ELF version, but would need to add an ELF loader.
TODO.
Interpreter is going fast enough that in theory Quake should be usable.
Also, unlike the current form of JX2VM, it does partly use the native FPU.
Goes and tries to build Quake for XG3... First up needed to fix a bunch of compiler crashes (mostly stepping on internal breakpoints). Got it building now, but it seems things are very broken here, so it seems I still need to do a bit more debugging on this front.
I guess, could debate whether it might make sense to add XG2 support to the new interpreter.
Did get a "semi working" Quake build for Rv64 running in the interpreter.
Seems:
VM Build with /O2 /Zi, generally fast enough to get ~ 40 fps in Quake,
though, varies wildly and covers a range from 30 to 60.
VM build with /O2, mostly hanging out near 60 fps.
There seems to be a 60 fps limiter here.
Similar to the 34 fps limiter in Doom.
Things are still pretty buggy, but interpreter seems to be moderately fast at least.
Also appears Quake running at ~ 200 MIPs is pretty much in 40+ fps territory (and ~ 250 MIPs hits 60 fps).
Then again, some might not be happy unless it is near-native performance, but not likely to happen with an interpreter.
Like, while ~ 250-300 MIPs is "slow", there isn't a whole lot to shave off here either in terms of hot-path logic...
Any thoughts?...
Haut de la page
Les messages affichés proviennent d'usenet.
NewsPortal