Scope: In-depth review of the bytecode manipulation code in ../serialthreads, focused on the efficiency of the generated bytecode, i.e. reducing the runtime of an enhanced application. jem (C64 emulator) is used as the reference workload. All file references are relative to ../serialthreads/src/main/java/org/serialthreads/ unless stated otherwise.
Reviewed: transformer/strategies/* (esp. frequent4), transformer/code/*, transformer/analyzer/*, context/Stack, context/StackFrame, context/SerialThreadExecutor, plus jem's usage (SerialClock, CPU6510, Tick, VIC components).
jem uses Strategies.DEFAULT = FREQUENT4. jem's SerialClock re-enters every component's run() on every clock tick, and Tick.waitForTick() is an @Interrupt, which always yields. So per tick and per component the enhanced code:
- Unwinds: at the interrupt site, the innermost method captures its live locals into its
StackFrameand returnstrue; every caller seestrue(IFEQcheck after the call), captures its live state, setsframe.method, and returnstrue— all the way up torun(). - Rewinds: on the next tick,
run()re-dispatches viaTABLESWITCHonframe.method, invokes the$$…$$copy method of the callee, which re-dispatches, restores locals, … down to the innermost frame, which continues after the interrupt.
For a typical CPU tick the chain is ~5 levels deep (run → execute → Opcode.execute → readBytePC → read). Estimated ~150–200 instructions of capture/restore/dispatch machinery per tick vs. a few dozen instructions of actual emulation logic — the machinery dominates the emulator's runtime, so every constant shaved off this path is visible wall-clock time.
The design is already strong:
- Liveness-based capture (
ExtendedAnalyzerbackflow analysis /ExtendedFrame.neededLocals) — dead locals are neither captured nor restored. - Constant and duplicate-local elision in
CompactingStackCode(constants are re-pushed, aliased locals copied instead of restored). - Fast fields for the first 8 slots per type (
StackFrame.stackInt0…,localObject0…). - Tail-call elision (
TAG_TAIL_CALLskips capture/restore entirely). setMethodskipped for methods with ≤ 1 interruptible call; single-restore dispatch degenerates to aGOTO.
The findings below are the remaining gaps, ranked by expected impact for jem.
AbstractMethodTransformer.analyze() (strategies/AbstractMethodTransformer.java:151) tags a call TAG_TAIL_CALL only when the very next real instruction is a return. The idiomatic Java
int result = read(pc);
return result; // jem: CPU6510.readBytePC(), line 3590compiles to ISTORE 2; ILOAD 2; IRETURN, so the tail call is missed. Consequences for such a method:
needsFrame()(strategies/frequent4/MethodTransformer.java:351) returnstrue→ every entry payspreviousFrame.next(plus null-check/addFramebranch) instead of reusing the previous frame.- Capture/restore code is generated although a genuine tail call would skip both (
captureFrame/restoreFrameemit nothing forTAG_TAIL_CALL,code/CompactingStackCode.java:34,104).
Recommended fix (transformer): recognize store x; load x; return (with only pseudo-nodes in between) as a tail call.
Better, semantic fix: make needsFrame() liveness-based instead of syntactic — the javadoc itself calls the third condition "somewhat suboptimal". The liveness data already exists: if a method has exactly one interruptible call and captureFrame would emit nothing (no live locals, no saved stack), no frame is needed regardless of call shape. This also covers void methods ending in a tail interrupt (jem: CPU6510.write() ending in tick.waitForTick(); MemAccessUnit.waitForTick()), because interrupts never receive the tail tag: the if/else if in analyze() (AbstractMethodTransformer.java:149-154) makes TAG_INTERRUPT and TAG_TAIL_CALL mutually exclusive even when the interrupt is in tail position and its capture set is empty.
OriginalMethodTransformer.createRestoreHandlerMethod() (strategies/frequent4/OriginalMethodTransformer.java:64) stores the owner eagerly in the prologue of every non-static interruptible method. During normal (non-yielding) execution this is a dead store — the owner is only read by the caller's restore code (pushOwner, used in callCopyMethod). jem's opcode implementations call several CPU helper methods per tick, so this is multiple wasted PUTFIELDs per tick. (Field stores are observable side effects; the JIT cannot eliminate them.)
Recommended fix: emit the owner store inside the capture blocks instead — both the interrupt block (createCaptureAndRestoreCodeForInterrupt) and the serializing branch after each method call (createCaptureAndRestoreCodeForMethod). Those blocks execute at most once per tick per level, and both have ALOAD 0 and localPreviousFrame available. Same instruction count, strictly fewer executions (captures ≤ entries).
Because FREQUENT4 methods return the serializing flag, real return values travel via thread.returnInt etc. Every non-void interruptible call — also on the never-interrupted fast path — pays:
- Callee return (
replaceReturns,strategies/frequent4/MethodTransformer.java:155):ALOAD frame; GETFIELD stack; SWAP; PUTFIELD returnInt; ICONST_0; IRETURN - Caller (
createCaptureAndRestoreCodeForMethod, line 264):IFEQ; ALOAD frame; GETFIELD stack; GETFIELD returnInt
The GETFIELD stack pairs are real memory traffic: the opcode-level INVOKEINTERFACE Opcode.execute$$…$$ is megamorphic (256 opcode classes in CPU6510), so the JIT cannot inline across it and cannot forward these stores to loads.
Note that FREQUENT3 already passes the Stack as a parameter (strategies/frequent3/MethodTransformer.java:110, popReturnValue(localThread)) — one fewer GETFIELD per site, at the price of one extra call argument (which lives in a register). FREQUENT4 traded that away.
Options, cheapest first:
- In methods with ≥ 2 return-value sites, hoist
frame.stackinto a synthetic local once in the prologue. - Reintroduce the thread parameter. This can be A/B-tested today in jem by benchmarking
Strategies.FREQUENT3vsFREQUENT4inC64Serial. - Best but invasive (a "FREQUENT5"): hybrid return convention — void methods keep returning the flag (optimal, zero extra cost); non-void methods return their real value normally and the caller checks
thread.serializing(one predictableGETFIELD+ branch, with the thread cached in a local/parameter). This removes the entirereturnXXXdance from the fast path of value-returning methods (read(),readBytePC()), which dominate a CPU emulator.
clear == true for references roughly doubles the restore cost of every object local/stack slot:
popLocalFast(code/AbstractValueCode.java:285):ALOAD frame; GETFIELD localObjectN; CHECKCAST; ASTOREplusALOAD frame; ACONST_NULL; PUTFIELD localObjectN.popStackFast/popStackSlowandpopReturnValuecarry analogous extra sequences (withDUP/SWAPjuggling in the slow path).
The StackFrame TODOs (context/StackFrame.java:266,330) show this was already suspected. Since frames are pooled and slots are overwritten by the next capture, the only benefit is slightly earlier GC eligibility of the referenced object — and in jem the captured objects (state, components, opcode receivers) are immortal anyway.
Recommended fix: drop the clearing, or gate it behind a transformer option (default off). Easiest pure win in this list: jem's run() loops capture/restore object locals like state every single tick.
Each frame eagerly allocates ten arrays of 64 elements (~2.5 KB) that the generated fast path never touches (fast fields cover the first 8 slots per type), on top of 80 scalar "fast" fields — the object spans many cache lines, and capture/restore is exactly the code that walks it every tick.
Recommended fixes:
- Allocate the overflow arrays lazily — the slow-path push/pop methods and
resize()are the only consumers. - Shrink
DEFAULT_FRAME_SIZE(64) drastically; methods with > 8 live locals of one type across a yield are rare. - Reconsider
FAST_FRAME_SIZE = 8for all five types × stack+locals (= 80 fields, 500+ bytes per frame); 4 may be the cache-friendlier sweet spot. Worth measuring — footprint effects don't show in instruction counts.
- Dead receiver push at interrupt sites.
replace(methodCall, …)increateCaptureAndRestoreCodeForInterruptremoves only the call instruction; the receiver push (ALOAD 0; GETFIELD tick) survives and executes every tick, its result silently discarded by the followingIRETURN(legal: the operand stack need not be empty at return). C2 usually dead-code-eliminates it after inlining, but it inflates bytecode. getNextFrame(..., addIfNotPresent=true)(code/AbstractStackCode.java:139) re-checksnext == nullwith aDUP/IFNONNULL/POPdance on every entry. Maintaining an "always one spare frame" invariant inStack/StackFrame.addFrame()would remove the branch; it is well-predicted, so low priority.- Bytecode size feeds JIT inlining thresholds (
FreqInlineSize≈ 325 bytes for hot methods). All injected code inflates transformed methods; keeping injected sequences minimal (findings 2.1–2.4) compounds, because inlining is what lets C2 elide flag checks and forward field traffic. fixMaxs()overestimatesmaxStack; harmless at runtime (COMPUTE_FRAMESrecomputes maxs), only relevant for theCheckClassAdapterpath.AbstractTransformerenablesCheckClassAdapter+ double disassembly whenever debug logging is on (check = logger.isDebugEnabled(),strategies/AbstractTransformer.java:67) — a startup-time trap worth documenting: never benchmark with debug logging enabled.
These do not affect jem today (its interruptible methods return only void/int), but produce broken bytecode when hit:
AbstractValueCode.popReturnValue()is wrong for reference types (code/AbstractValueCode.java:364). Withclearset, the emitted sequence isDUP; GETFIELD returnObject; CHECKCAST; ACONST_NULL; PUTFIELD. At thePUTFIELD, the objectref on the stack is the returned value, not the thread — any interruptible method with a reference return type called in non-tail position yields aVerifyError(orCOMPUTE_FRAMESfailure) at transform/load time. Fix:SWAPafter theGETFIELD(and reorder the cast).pushReturnValue()handles the analogous case correctly withDUP_X2/POP.pushStackFast/pushStackSlowuseSWAPunconditionally (code/AbstractValueCode.java:158,167).SWAPis illegal on category-2 values, so capturing along/doublethat is live on the operand stack at a yield point produces invalid bytecode. Fix: use theDUP_X2; POPidiom (as inpushReturnValue) whensize == 2.- Interrupt replacement assumes an empty expression stack. If an
@Interruptcall ever sits above a non-empty evaluation stack,saveStackmisaligns by one slot because the dangling receiver is still on the real stack whileframeAfterdoesn't contain it. Unreachable from javac-compiled statement-position calls, but worth an assertion onmetaInfo.frameAfterincreateCaptureAndRestoreCodeForInterrupt.
Since an interrupt always yields, each tick pays O(call depth) invokes + TABLESWITCH dispatches to rebuild the Java stack, then O(depth) returns to tear it down. The unused StackFrame.methodHandle field suggests this was already considered: store a MethodHandle (or generated per-site continuation entry) per frame, have the executor invoke the innermost frame directly, and trampoline upward only when a method actually returns. That converts the per-tick rewind from O(depth) to O(1) at the cost of slower returns from interruptible methods. Given jem ticks on every memory access but returns from interruptible methods comparatively rarely, the trade likely pays off — but it is a new strategy, not a tweak.
- Write
return read(pc);instead ofint result = read(pc); return result;inCPU6510.readBytePC()(and similar) — with the current transformer this literally decides whether the method gets a frame (finding 2.1). - Remove one-line
@Interruptiblewrappers likeMemAccessUnit.waitForTick()— each wrapper level costs a full frame's entry/capture/restore/dispatch every tick. - Avoid caching fields in locals across yields (
var state = this.state;inCPU6510.run()): the cached local must be captured and restored every tick; re-reading the field per use is cheaper under this transformation. - Benchmark
Strategies.FREQUENT3vsFREQUENT4inC64Serial— a one-line change that directly measures the return-value-convention question (finding 2.3).
- Baseline: jem Lorenz suite wall-clock (
LorenzTest) and/or a fixed-tickdoRun(int ticks)micro-run, plus the existing../serialthreads/src/test/performancecounter tests. - Apply findings in isolation (2.4 null-clearing and 2.2 owner store are lowest-risk), re-measure each.
- Verify transformed bytecode with the existing
Debugger/CheckClassAdapterpath (transformer.check()), and keep debug logging off while timing.
| # | Finding | Type | Effort | Expected win (jem) |
|---|---|---|---|---|
| 2.4 | Drop null-clearing on restore | perf | low | medium |
| 2.2 | Owner store: entry → capture | perf | low | medium |
| 2.1 | Tail-call detection / liveness-based needsFrame() |
perf | medium | high |
| 2.3 | Return-value convention (frame.stack hoist / thread param / hybrid) |
perf | medium–high | high |
| 2.5 | StackFrame footprint (lazy arrays, sizes) |
perf | low–medium | small–medium |
| 3.1 | popReturnValue reference bug |
correctness | low | — |
| 3.2 | SWAP on category-2 stack values |
correctness | low | — |
| 4 | Trampolining strategy | perf | very high | potentially large |