Skip to content

Shuffle less for SSE4.1#137

Closed
TobiSchluter wants to merge 3 commits into
vitaut:mainfrom
TobiSchluter:shuffle_less
Closed

Shuffle less for SSE4.1#137
TobiSchluter wants to merge 3 commits into
vitaut:mainfrom
TobiSchluter:shuffle_less

Conversation

@TobiSchluter

@TobiSchluter TobiSchluter commented May 7, 2026

Copy link
Copy Markdown
Contributor

In the current code, we shuffle to reverse the byte order and we shuffle to shift the bits. This combines the two. It also replaces a slow memmove for the decimal digits with another shuffle + store, so that the data is never re-read from memory.

This is my first AI-assisted patch to zmij, and the AI's main task was to figure out the special cases when moving the writing of the last digit behind the shuffles. Previously, the last_digit only sometimes was part of the memmove and could thus end up in a different position. The AI suggested to keep a table of where it goes, and so I did that. A table-free approach made the compiler add a branch and lose time.

The way I avoided to increase the table size for the various bswap + shift variations and thus messing up alignments or introducing padding is quite simple: what we want to do is to reverse the order and drop the first N digits, where N depends on whether its the integral part or the decimals, and it depends on the exponent. We don't care what is added in the end past the actual digits. So, it doesn't actually matter what the last few entries in the shuffler are, and so we just keep the same bswap shuffle table, read it at an offset and don't care what the entries at the end end up being. This only works because we reverse at the same time, so this cannot be carried to the NEON code. The additional bytes are loaded from the same struct, so it's not uninitialized memory and ASAN is happy, but I'm not sure what the standard says about this. I don't think the SIMD memory access is in its scope. BTW I changed the type of bswap to char * in order to get simpler index algebra and less casts.

Finally, in order to keep everything branch-free, in the case of dec_exp < 0 where no decimal point is inserted between the digits, the identical shuffle + store operation is simply repeated. This turned out to be measurably faster than conditionally jumping past it. Note that the zmij/canada benchmark, given Canada's geography, doesn't contain any numbers < 1, so I created a separate benchmark (not included) that uses random fixed numbers. To my surprise it was actually faster than the processing of the Canada data. A possible explanation is that it triggered the last_digit far less often but I didn't check this in detail.

On my Zen 4 this saves 5%, on my Tiger Lake it is performance neutral.

I also asked Claude to do try the same thing for NEON but none of the variations it tried came out faster than what is there right now.

ps I just saw that I didn't clean this up fully. Hence the non-squashed three-patch series with one unrelated change that I didn't mean to commit as part of this: I moved the alignas(64) annotation from the static_data declaration to the struct data declaration. The idea being that this way all the pointers that we use remember the alignment. Turns out the assembly with and without this change is the same on x64 https://godbolt.org/z/Mxz1fbKjY and ARM64 https://godbolt.org/z/ecnPzTWhK, but I think it's cleaner, so I'm not going to remove it right now even though it is unrelated.

@vitaut

vitaut commented May 9, 2026

Copy link
Copy Markdown
Owner

It also replaces a slow memmove for the decimal digits with another shuffle + store, so that the data is never re-read from memory.

Which memove exactly are you referring to and would it make sense to split it into a separate PR to evaluate optimizations effects independently? Or is it a side-effect of the main optimization?

Comment thread zmij.cc Outdated
pack8(7, 6, 5, 4, 3, 2, 1, 0)};
// We will read from bswap at offsets for fixed format output. Accomodate
// the necessary offset calculations by using char*.
// This is aligned to 64bits, so we won't cross a cache line when reading,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bits or bytes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good eyes! Will correct

@TobiSchluter

TobiSchluter commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

It also replaces a slow memmove for the decimal digits with another shuffle + store, so that the data is never re-read from memory.

Which memove exactly are you referring to and would it make sense to split it into a separate PR to evaluate optimizations effects independently? Or is it a side-effect of the main optimization?

The second one which reads the data immediately written before. This dominated the profile.

I will commit a simplification of the evaluation of the new stuff in float_layout tonight. It's probably not surprising that the AI stuff was a bit too roundabout. I will then split this in, probably, three patches which can be benchmarked in sequence: 1. Move writing last_digit to the end with helper entries in fixed_layout 2. Merge bswap and writing of digits 3. Replace memmove for decimal places with another shuffle

@TobiSchluter

Copy link
Copy Markdown
Contributor Author

@vitaut so I made the mistake of benchmarking not only with gcc but also with clang while reworking the patch: the whole thing is a bit of a mirage, with clang this is actually a pessimization, and the speed up with gcc only brings it to up to par with the pesssmized clang. So, I think this patch is misguided, and the apparent gain is actually due to gcc being led away from some questionable choices it makes in the original version. OTOH thinking about this, it became clear that I should just do what I had considered but dismissed, namely put together the string and put the decimal point in the middle while still inside the SSE register, and only do a single store to memory (+ extra_digit and last_digit). This seems to work well, and I hope to have it ready for submission later this week.

@vitaut

vitaut commented May 11, 2026

Copy link
Copy Markdown
Owner

Thanks for the detailed analysis. This sounds like a good plan and I look forward to the updated version.

@TobiSchluter

Copy link
Copy Markdown
Contributor Author

This takes a bit longer than planned because I went down a nano-optimization rabbit-hole on this and got distracted by the other patch I just posted. I wanted to finish this today, but due to a sick child it will have to wait until Monday.

@vitaut

vitaut commented May 15, 2026

Copy link
Copy Markdown
Owner

No rush and hope your child gets better soon.

@TobiSchluter
TobiSchluter force-pushed the shuffle_less branch 2 times, most recently from d5c1463 to d2ceb6a Compare May 21, 2026 13:58
@TobiSchluter

Copy link
Copy Markdown
Contributor Author

I'm sorry, I had written a long comment about the changes, but seem to not have submitted it 🫤 Sorry about that, will recover it later.

@TobiSchluter

TobiSchluter commented May 22, 2026

Copy link
Copy Markdown
Contributor Author

I still had the edit window open on this PC, so I could restore the message:

This series became more interesting than I'd hoped, and so it took a bit longer to converge on something. Everything I tried beyond this -- if it made any change at all -- made clang regress and gcc improve. Seems like compilers don't like producing optimal code. Here are the numbers on my Zen4:

compiler bench base test Δ% p verdict
gcc zmij (scientific) 7.51ns 7.56ns +0.6% 1.3e-3 SLOWER (marginal)
gcc zmij/canada 7.50ns 7.21ns −3.9% 4.6e-156 FASTER
gcc zmij/fixed_range 7.51ns 7.27ns −3.3% 6.6e-49 FASTER
clang zmij (scientific) 6.81ns 6.72ns −1.4% 2.5e-14 FASTER
clang zmij/canada 7.22ns 6.57ns −8.9% 3.6e-231 FASTER
clang zmij/fixed_range 6.73ns 6.56ns −2.5% 2.9e-19 FASTER

You will note that I added a new benchmark. This exercises the whole exponent range for the fixed numbers. Canada is situated in a geographical situation that limits the range of numbers encountered, and small numbers exercise a slightly different code path (the initial zeros are used), and so it's worth making sure that there is no regression.

You will also notice that the benchmark numbers are within the 2-3% speedup range except for clang's zmij/canada test where a massive gain obtained. This is the result of the third patch in the series, which does something very simple (sorry about the AI generated commit message, I only noticed as I'm writing this that I didn't turn it into something readable). This patch moves the to_digits call on the fixed path after the memcpy which puts the zeros in place. I thought that a memcpy to a char * might be allowed to alias something that is used and thus may pessimize the code, so I tried this change of order. I'm not sure if that's the explanation, but clang suddenly sped up a few notches, so I'm happy. Now to give you an illustration of how random this is: on my Tiger Lake laptop this patch is actually performance neutral and doing only the change of order has neglible performance impact.

What the patch does is not very complicated: instead of memcpyíng the digits around to create space for the decimal point, it shuffles them around while still in a SIMD register. For that it uses a precalculated shuffle table, and in order to be able to efficiently insert the last_digit, it uses a table of insertion positions contained in an array indexed by extra_digit and the length of the significand. I also changed the length ternary to a select() because amid the chain of patches, gcc decided that the existence of last_digit should be predictable, and it could jump, making performance regress badly.

I will not bore you with a recount of all the things I tried, as you can tell from the impact of the reordering, it can be quite unpredictable how things will benefit.

@vitaut

vitaut commented May 24, 2026

Copy link
Copy Markdown
Owner

Thanks for the update!

The new benchmark makes sense but please move it to a separate PR and also rebase this one since there were some changes to the shuffle table.

@TobiSchluter TobiSchluter mentioned this pull request Jun 7, 2026
Instead of inserting the decimal point by writing to memory once
and then shifting everything past the point over, we create space
by shuffling the simd register and then fix up the extra digit
which have moved past the end of the register.  The shuffle is
added to the fixed_layout_table.

Mechanics: per dec_exp, fixed_layout stores two 16-byte pshufb shuffle
tables (one per extra_digit) that place the natural-order BCD bytes
returned by to_digits into their final output positions, with the byte
at the decimal-point position (when it falls inside the 16-byte
vector) replaced by a pshufb zero-marker.  The fast path is:

  - one shuffle-table load + one pshufb + one 16-byte store
  - byte store of BCD[15] at buffer[16] (only meaningful for
    extra_digit == 1 with 0 <= dec_exp <= 14, where that byte sits
    outside the vector window; in every other case the slot is
    overwritten below by '.' or last_digit, so the write is
    unconditional and harmless)
  - byte store of '.'
  - byte store of last_digit

Compared to main on -march=x86-64-v2, RelWithDebInfo, taskset -c 0
(16 repetitions, ns/double):

  bench         gcc            clang
  --------      ----           ----
  zmij/fixed    7.41 -> 6.88   6.99 -> 6.60   (-7% / -5%)
  zmij/canada   7.54 -> 7.01   7.29 -> 7.08   (-7% / -3%)
  zmij (mixed)  flat within stddev on both compilers
Two changes that together give clang ~5–7% on the fixed-path canada
benchmark, neutral on fixed_range and on the scientific path, and are
~neutral on gcc (combined with the preceding select() commit):

* Push `auto dig = to_digits(...)` into the two branches that consume
  it.  In the fixed-path arm this scopes the result to that branch,
  which lets clang interleave the tail of to_digits (specifically the
  final `por '0'` ASCII offset) with the surrounding scalar work
  instead of forcing it on the critical path before the dec_exp range
  check.  Counted once per call regardless of branch, so no extra
  work; the duplication is purely a scheduling hint.

* Move the unconditional `buffer[bcd_size] = pextrb(digits, 15)` write
  ahead of the `pshufb`/`memcpy` pair.  This lets the extract retire
  in parallel with the shuffle instead of after it.
@TobiSchluter
TobiSchluter force-pushed the shuffle_less branch 2 times, most recently from e7e8b47 to bcb7890 Compare June 7, 2026 13:47
@TobiSchluter

Copy link
Copy Markdown
Contributor Author

Updated to current main and separated out the fixed benchmark in #143
@vitaut looking forward to your thoughts on both.

Comment thread zmij.cc Outdated
@vitaut

vitaut commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Confirmed the improvement on gcc on the first commit (rebased):

$ test/abtest.py c5998fb9a7ec37a55d931ef05138d296074962cc 67f865d878a1e307bf4d8aa09
eecac3bac2b411d
benchmark                    base       test      Δ%         95% CI        p  verdict
zmij/fixed_range          14.38ns    13.18ns   -8.3%  [ -9.4, -7.3]  2.6e-55  FASTER
zmij/canada               14.11ns    13.21ns   -6.3%  [ -7.8, -4.9]  4.8e-18  FASTER
zmij                      13.93ns    13.80ns   -0.9%  [ -2.1, +0.4]     0.17  NOISE
dragonbox/canada          22.79ns    22.63ns   -0.7%  [ -2.1, +0.7]     0.32  NOISE
dragonbox                 44.39ns    44.35ns   -0.1%  [ -0.7, +0.6]      0.8  NOISE
dragonbox/fixed_range     35.25ns    35.27ns   +0.1%  [ -0.6, +0.7]     0.88  NOISE

and no effect on gcc on the second commit:

benchmark                    base       test      Δ%         95% CI        p  verdict
zmij/fixed_range          13.20ns    13.38ns   +1.3%  [ +0.2, +2.5]    0.026  NOISE
zmij                      13.80ns    13.90ns   +0.7%  [ +0.1, +1.4]    0.031  NOISE
zmij/canada               13.31ns    13.33ns   +0.1%  [ -1.2, +1.4]     0.88  NOISE
dragonbox                 44.32ns    44.35ns   +0.1%  [ -0.3, +0.4]     0.76  NOISE
dragonbox/fixed_range     35.23ns    35.24ns   +0.0%  [ -0.4, +0.5]     0.84  NOISE
dragonbox/canada          22.52ns    22.51ns   -0.0%  [ -0.5, +0.5]      0.9  NOISE

@vitaut

vitaut commented Jun 27, 2026

Copy link
Copy Markdown
Owner

The results on my clang version on epyc are mixed though (d1d9c2d vs 686bfa0):

benchmark base test typical Δ% consistent verdict
zmij/canada ~16.7ns ~16.4ns −2% to −1% FASTER (once NOISE)
zmij ~21.0ns ~21.2ns +1% SLOWER (once NOISE)

So I've merged the first commit only for now.

@vitaut

vitaut commented Jun 27, 2026

Copy link
Copy Markdown
Owner

I'm going to close this PR for now and for clang changes I suggest opening a new PR with a bit more detail about the improvements and how to repro them. Thanks!

@vitaut vitaut closed this Jun 27, 2026
@TobiSchluter

Copy link
Copy Markdown
Contributor Author

Thanks! Also for looking at the diffs separately.

I've found it remarkably hard to get reliable speed measurements. Not only do different microarchitectures have different eprformance characteristics, but there is also less predictable stuff. E.g., with clang19 there's sometimes a massive speed regression on zen5 that I cannot at all understand from the assembly. Opus is hypothesizing that it's about the code being so small that more than one iteration of the benchmark fits in the out-of-order buffer, and one thus sees unrealistic and unpredictable pressure on the multiplication and shuffle ports. I've no idea if that holds, it also asserts a lot of things that are easy to disprove, but given the things that I saw with integer conversion #40 I'm willing to assume that CPUs indeed look at a lot of code simultaneously.

Anyhow, I will make sure to include more details of the benchmarks going forward.

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