--- name: cheap-masm-equivalents description: Use when writing or reviewing MASM hot paths or loops — prefer the cheaper equivalent: loop counters and pointers on the operand stack instead of procedure locals, `neq.0` over `gt.0` for non-zero checks, `cdrop` over an `if/else` selecting between two values, `dup.N` over `loc_load` for a value still on the stack, `eqw` over element-wise word comparison, `u32gt`/`u32lt` over generic `gt`/`lt` on known-u32 operands. --- # Prefer Cheap MASM Equivalents ## Rule Several MASM idioms have a cheap and an expensive form. Use the cheap one when both produce the same result on the inputs the procedure can see: - Loop variables (counters, pointers, indices): keep them on the operand stack across iterations instead of in procedure locals. See below. - Non-zero check: `neq.0` (3 cycles) over `gt.0` (16 cycles). - Selecting between two values on a flag: `cdrop` over an `if.true ... else ... end` branch with the same effect. - Re-fetch a recently-pushed value: `dup.N` over `loc_load.N` when the value is still on the stack. - Whole-word equality: `eqw` over element-wise comparisons. - u32-known operands: `u32gt`/`u32lt` over generic `gt`/`lt`. Don't apply the cheap form when the operands violate its precondition (e.g. `u32gt` on a value that might exceed `u32::MAX`). ## Why MASM cycle costs are not uniform — `gt.0` does signed-comparison work that `neq.0` skips, so a hot path using the expensive form pays for it on every call. The swaps are semantically equivalent under their preconditions, so the saving is free. ## Examples ```masm # Good push.0 neq # non-zero check, 3 cycles # or simply neq.0 # Bad push.0 gt # same answer, 16 cycles ``` ```masm # Good: cdrop for ternary selection # stack: [c, b, a] cdrop # stack: [b if c = 1 else a] # Bad: branchy equivalent if.true swap drop # drop a, keep b else drop # drop b, keep a end ``` ## Loop Variables Belong on the Stack A procedure local is not a register: `loc_load.i` costs 5 cycles and `loc_store.i` costs 6. Reaching the same value on the stack with `dup.n`, `swap`, `movup.n` or `movdn.n` (usually) costs 1 cycle. So a loop that keeps its counter and pointer in locals pays 5-11 cycles per access, per iteration, for data the stack could hold for 1. Read once, mutate in place: ```masm # Good: item_ptr lives on the stack next to the loop counter # => [items_left, item_ptr, ...] # 1 cycle: read the pointer dup.1 # ... use it ... # 4 cycles: advance it swap add.ITEM_NUM_ELEMENTS swap sub.1 dup neq.0 # Bad: same loop through a local # 5 cycles loc_load.ITEM_PTR_LOC # ... use it ... # 13 cycles loc_load.ITEM_PTR_LOC add.ITEM_NUM_ELEMENTS loc_store.ITEM_PTR_LOC sub.1 dup neq.0 ``` ### Working around `call` The reason to reach for a local is a `call`: the callee takes the top 16 elements, so while those 16 slots are being filled, nothing below them is addressable by `dup.n`. Values that only have to *survive* the call are fine on the stack - they sit in the overflow and come back untouched. Only a value that must be re-read *while* the frame is being built has to live in a local, and even then it is one local, not one per loop variable.