Skip to content

[ARM64_DYNAREC] Skip dead scalar SSE upper-lane preserve - #4143

Open
jserv wants to merge 1 commit into
ptitSeb:mainfrom
jserv:arm64-skip-dead-sse-upper-lane
Open

[ARM64_DYNAREC] Skip dead scalar SSE upper-lane preserve#4143
jserv wants to merge 1 commit into
ptitSeb:mainfrom
jserv:arm64-skip-dead-sse-upper-lane

Conversation

@jserv

@jserv jserv commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

This tracks liveness for the dead upper lanes of the low XMM registers separately from whole-register XMM liveness: an upper-64 lane for the scalar-double ops and an upper-96 lane for the scalar-single ops. Scalar operations mark their operands as not genuinely reading those upper bits, while barriers and unknown exits keep the conservative all-needed behavior.

Use that liveness in the ARM64 F2 0F scalar-double lowering to compute SQRTSD, ADDSD, MULSD, SUBSD, and DIVSD directly in the destination when the destination upper-64 is proven dead, and in the F3 0F scalar-single lowering for SQRTSS, ADDSS, MULSS, SUBSS, and DIVSS when the destination upper-96 is proven dead. The existing preserve path remains the fallback for live or unknown upper lanes and for FASTNAN-disabled paths. MOVSS store and the other scalar-single ops mark their low-32 operands so a stored scalar result does not pin the upper lane live (mirroring MOVSD store, which was the difference that let the scalar-double path fire while the scalar-single one did not).

Benchmark: static x86 benchfloat (LINPACK) on an Ampere eMag, n=9 core-pinned runs, BOX64_DYNACACHE=0 to defeat the on-disk dynarec cache, parent e3161999c built as the "before". Median KFLOPS:

double -O2 (scalar-double path)
  SAFEFLAGS=1 : 637,631.540 -> 700,135.472   +9.8%   (MAD 226.468)
  SAFEFLAGS=0 : 765,920.767 -> 853,745.312   +11.5%  (MAD 936.058)
single -O2 (scalar-single path)
  SAFEFLAGS=1 : 513,127.344 -> 611,984.438   +19.3%  (MAD 264.312)
  SAFEFLAGS=0 : 566,161.312 -> 696,077.312   +23.0%  (MAD  65.313)

Host: Ampere eMag 8180; 1 socket x 32 cores x 1 thread; up to 3.0 GHz; Arm64 Linux v6.8.0.

Comment thread src/dynarec/arm64/dynarec_arm64_f20f.c Outdated
@ptitSeb

ptitSeb commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Overall, I don't understand how it works. math operationo SD are supposed to preserve the upper 64bits part, while ARM64 operation will set the upper part to 0. The only way to know if a math operation is safe to do without preserving the upper 64bits is if the register has been loaded with a MOVSD.
What happens if a block start with a SQRTSD operation for example? will the upper part be preserve or zero'd out?

@jserv
jserv force-pushed the arm64-skip-dead-sse-upper-lane branch from 597e063 to a774255 Compare July 28, 2026 15:31
@jserv

jserv commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Overall, I don't understand how it works. math operationo SD are supposed to preserve the upper 64bits part, while ARM64 operation will set the upper part to 0. The only way to know if a math operation is safe to do without preserving the upper 64bits is if the register has been loaded with a MOVSD.

The proposed change reuses box64's existing upper-lane liveness rather than introducing a new heuristic. box64 already tracks the AVX ymm upper-128 lanes (ymm_{needed,unneeded}) to decide zero-vs-preserve; this adds the SSE analog for the upper-64 of the low 16 XMM (xmmh_{needed,unneeded}), computed in the same updateUneeded() pass by the same propagateXYMM{Needed,Uneeded} walkers.

The in-place path (which zeroes the upper) is taken only when the upper-64 is provably dead: no reachable instruction reads the full register before the lane is overwritten. It's the same backward propagation as whole-xmm; the only seeding difference is:

xmmh_used = xmm_used & ~xmm_scalar;

A scalar SD op is flagged in xmm_scalar (via MARK_XMM_SCALAR), so it's masked out of xmmh_used: it does not count as reading the upper lane. Any full-width reader (MOVAPS, UNPCKHPD, PXOR, …) is not scalar, keeps its xmmh_used bit, and pins the lane live back to its producer, forcing the preserve path.

What happens if a block start with a SQRTSD operation for example? will the upper part be preserve or zero'd out?

The upper is preserved, not zeroed. That SQRTSD only gets the in-place path if xmmh_unneeded is set on it, which requires a later statically-resolved full overwrite of the same register with no full-width read in between. A block that does SQRTSD and then leaves to an unresolved target hits the boundary in updateUneeded():

if ((barrier & BARRIER_FLOAT) || (jmp && jmp_insts == -1)) {
    ...
    xmmh_needed = 0xffff;   // every upper lane pinned live at an unresolved exit
}

so the lane is live across the exit and stays preserved. A lone/leading SQRTSD therefore keeps the incoming upper. The MOVSD-loaded case you mention is subsumed: the load makes the old upper dead, exactly like any later overwrite. If a full-width read is reachable, the lane is never zeroed.

@ptitSeb

ptitSeb commented Jul 28, 2026

Copy link
Copy Markdown
Owner

nope, there is still I don't get (but I appreciate that you reused existing mecanism). I still don't understand why MOVSD & SQRTSD have basicaly the same change, while one is a load that should start a chain, while the second is an operation that can benefit from the chain.

Can you do some dumps with your sample case so I understand better the effects in the code?

@ptitSeb

ptitSeb commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Also, side not: what about the SS operation with the 1 float?

@ptitSeb

ptitSeb commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Also also, thos new xmmh_used & friend should be printed in dump (on inst_name_pass3(...) function from dynarec_arm64_functions.c

This tracks liveness for the dead upper lanes of the low XMM registers
separately from whole-register XMM liveness: an upper-64 lane for the
scalar-double ops and an upper-96 lane for the scalar-single ops. Scalar
operations mark their operands as not genuinely reading those upper bits,
while barriers and unknown exits keep the conservative all-needed
behavior.

Use that liveness in the ARM64 F2 0F scalar-double lowering to compute
SQRTSD, ADDSD, MULSD, SUBSD, and DIVSD directly in the destination when
the destination upper-64 is proven dead, and in the F3 0F scalar-single
lowering for SQRTSS, ADDSS, MULSS, SUBSS, and DIVSS when the destination
upper-96 is proven dead. The existing preserve path remains the fallback
for live or unknown upper lanes and for FASTNAN-disabled paths. MOVSS
store and the other scalar-single ops mark their low-32 operands so a
stored scalar result does not pin the upper lane live (mirroring MOVSD
store, which was the difference that let the scalar-double path fire
while the scalar-single one did not).

Benchmark: static x86 benchfloat (LINPACK) on an Ampere eMag, n=9
core-pinned runs, BOX64_DYNACACHE=0 to defeat the on-disk dynarec
cache, parent e3161999c built as the "before". Median KFLOPS:

  double -O2 (scalar-double path)
    SAFEFLAGS=1 : 637,631.540 -> 700,135.472   +9.8%   (MAD 226.468)
    SAFEFLAGS=0 : 765,920.767 -> 853,745.312   +11.5%  (MAD 936.058)
  single -O2 (scalar-single path)
    SAFEFLAGS=1 : 513,127.344 -> 611,984.438   +19.3%  (MAD 264.312)
    SAFEFLAGS=0 : 566,161.312 -> 696,077.312   +23.0%  (MAD  65.313)

Host: Ampere eMag; 1 socket x 32 cores x 1 thread; up to 3.0 GHz; Arm64
Linux v6.8.0.
@jserv
jserv force-pushed the arm64-skip-dead-sse-upper-lane branch from a774255 to f7553ab Compare July 28, 2026 18:34
@jserv

jserv commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

I still don't understand why MOVSD & SQRTSD have basicaly the same change, while one is a load that should start a chain, while the second is an operation that can benefit from the chain.

MOVSD isn't given the skip. Only the arithmetic ops (SQRTSD/ADDSD/MULSD/SUBSD/DIVSD) get the in-place XMMH_UNNEEDED branch. What MOVSD store got is MARK_XMM_SCALAR(gd): the analysis input (this op reads only Gx's low 64), not the skip. So they are not the same change: every scalar op feeds the liveness pass; only the math ops consume it to drop the preserve.

Your "chain" intuition is right, just backward. The chain isn't started by a load; it's the backward dead-upper liveness seeded from wherever the upper is next fully overwritten. A MOVSD load from memory does zero the upper (a full
write), so it's one thing that seeds "upper dead" going backward, but so is any packed/full write. The math op benefits when that dead-upper reaches it with no full-width read in between.

Can you do some dumps with your sample case so I understand better the effects in the code?

Dumps from a tiny sample (using the new xmmHused/needed/unneeded markers):

Upper dead — SQRTSD followed by a MOVUPD that fully overwrites xmm3:

0x401760: SQRTSD  xmmHused=0000/needed=fff6/unneeded=0009
        1e61c063   FSQRT D3, D3            ; in place, one op, upper zeroed
0x401764: MOVUPD
        3dc00023   LDR   Q3, [x1]          ; full 128-bit overwrite => upper was dead

unneeded=0009 (bit3 = xmm3) → upper proven dead → preserve skipped.

Upper live — SQRTSD whose upper is then read by UNPCKHPD:

0x401760: SQRTSD  xmmHused=0000/needed=fffe/unneeded=0001
        fd400039   LDR   D25, [x1]
        1e61c338   FSQRT D24, D25          ; compute in scratch
        6e080703   INS   V3.D[0], V24.D[0] ; insert low, PRESERVING upper
0x401768: UNPCKHPD                          ; reads the upper => live

unneeded=0001 (bit3 not set) → the preserving INS stays. Same instruction, opposite decision, driven only by whether a reachable reader needs the upper.

@jserv

jserv commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Also, side not: what about the SS operation with the 1 float?

DONE. It needs its own upper-96 lane:
SS preserves bits[32:127], not just the [64:127] the double path tracks. Same needed/unneeded propagation as xmmh, one dword wider — a scalar-double read still pins [63:32], so only genuinely scalar-single ops are masked out, and the in-place form fires only when bits[32:127] are provably dead.

The unlock was exactly the store point from the other thread: MOVSS store was not marking its operand scalar the way MOVSD store already does. Without it, storing a scalar-single result pinned the upper lane live, so it never fired in real
loops — which is why the double path benefited and the single path first looked flat. With the mark, it fires.

Benchmark — static x86 benchfloat (LINPACK), Ampere eMag, n=9 core-pinned, BOX64_DYNACACHE=0, parent as "before":

single -O2 (scalar-single path)
  SAFEFLAGS=1 : 513,127 -> 611,984   +19.3%  (MAD 264)
  SAFEFLAGS=0 : 566,161 -> 696,077   +23.0%  (MAD  65)

(the scalar-double path on the same host was +9.8% / +11.5%.) SSE/x87/FPU ctests pass; a dump of SQRTSS followed by an ADDSD reading bits[0:63] confirms it keeps the preserve (upper-64 dead, upper-96 live) — the case the separate
lane exists for.

@jserv

jserv commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Also also, thos new xmmh_used & friend should be printed in dump (on inst_name_pass3(...) function from dynarec_arm64_functions.c

DONE. inst_name_pass3() now prints it next to needed/unneeded:

if (n.xmmh_used || n.xmmh_needed || n.xmmh_unneeded)
    length += sprintf(buf + length, " xmmHused=%04x/needed=%04x/unneeded=%04x",
                      n.xmmh_used, n.xmmh_needed, n.xmmh_unneeded);

plus an xmmSused=.../needed=.../unneeded=... line for the scalar-single lane. The dumps in the other replies use them.

@ptitSeb

ptitSeb commented Jul 28, 2026

Copy link
Copy Markdown
Owner

I still don't understand why MOVSD & SQRTSD have basicaly the same change, while one is a load that should start a chain, while the second is an operation that can benefit from the chain.

MOVSD isn't given the skip. Only the arithmetic ops (SQRTSD/ADDSD/MULSD/SUBSD/DIVSD) get the in-place XMMH_UNNEEDED branch. What MOVSD store got is MARK_XMM_SCALAR(gd): the analysis input (this op reads only Gx's low 64), not the skip. So they are not the same change: every scalar op feeds the liveness pass; only the math ops consume it to drop the preserve.

Your "chain" intuition is right, just backward. The chain isn't started by a load; it's the backward dead-upper liveness seeded from wherever the upper is next fully overwritten. A MOVSD load from memory does zero the upper (a full write), so it's one thing that seeds "upper dead" going backward, but so is any packed/full write. The math op benefits when that dead-upper reaches it with no full-width read in between.

Can you do some dumps with your sample case so I understand better the effects in the code?

Dumps from a tiny sample (using the new xmmHused/needed/unneeded markers):

Upper dead — SQRTSD followed by a MOVUPD that fully overwrites xmm3:

0x401760: SQRTSD  xmmHused=0000/needed=fff6/unneeded=0009
        1e61c063   FSQRT D3, D3            ; in place, one op, upper zeroed
0x401764: MOVUPD
        3dc00023   LDR   Q3, [x1]          ; full 128-bit overwrite => upper was dead

unneeded=0009 (bit3 = xmm3) → upper proven dead → preserve skipped.

Upper live — SQRTSD whose upper is then read by UNPCKHPD:

0x401760: SQRTSD  xmmHused=0000/needed=fffe/unneeded=0001
        fd400039   LDR   D25, [x1]
        1e61c338   FSQRT D24, D25          ; compute in scratch
        6e080703   INS   V3.D[0], V24.D[0] ; insert low, PRESERVING upper
0x401768: UNPCKHPD                          ; reads the upper => live

unneeded=0001 (bit3 not set) → the preserving INS stays. Same instruction, opposite decision, driven only by whether a reachable reader needs the upper.

Ah ok. I see now in what cases the xmmh_unneeed triggers! thanks for the detail explanation.

I'm currently preparing the next release on box64, so I will keep this PR open until the start of the next developement cycle after the release (should happens this week hopefully).

Side note: I was initialy reviewing this opcode with my initial idea for this in mind: to use a forward chain starting at with a MOVSD or MOVSS that garanties the upper part is zero... that's why I emphasis on MOVSD behaviour. But the backward chain for unused upper parrt is also a nice choice. Both idea are complementary I think, and I still might try to implement the forward chain later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants