zeux · GitHub

added 10 commits

June 9, 2026 20:18
Vertex block decoding currently decodes the block into a 8 KB stack
buffer and copies the result to the output buffer. While the memcpy is
fast, it's not free and the cost mostly can't be hidden behind the rest
of the decoding work. Decoding directly into the output buffer can be
faster.
However, the temporary copy here is deliberate because the write
patterns during decoding are bad for write-combined memory (16-32 byte
strided writes with up to 256 writes in sequence would require up to 256
cache lines resident in the write buffers which is usually way over the
hardware limits). Additionally we also read the last vertex of each
block from the buffer which won't go well with write-combined memory
either; and we don't have good ways to dynamically detect if the output
buffer is write-combined.
However in some cases we might know that the decoder is never used on
write-combined memory; in these cases, MESHOPTIMIZER_VERTEXCODEC_ZEROCOPY
can be defined to get faster decoding. This is always the case in Wasm,
where this gets us a ~5% speedup on real-world data, and could be the
case in specific applications that are welcome to use this too.
For SIMD decoding, we still need the fallback memcpy path for the tail
block as the delta decoding always works in groups of 16 vertices.
Similarly to the SSE optimization, the SIMD path has long latency and
can benefit from reducing the dependency through the critical edge which
is adjusting the pointer. While we can't use *fixed* bit math to compute
the count, we have access to popcnt and can do basic data dependent
masking to group all sentinel bits into one:
- When n=0, both shifts and final mask are no-op
- When n=1, one shift is a no-op and final mask isolates bottom bit
- When n=2, shifts reduce 4-bit into 1 bit and final mask isolates it
This results in 5-15% speedup on Zen4 depending on the data.
With latency optimization, we should be able to merge all cases into one
somewhat complex data dependent vector routine, which would eliminate
branch mispredictions if we completed it.
The code would need adjustments because neither 0 nor 8-bit case work
out of the box; this change merges only the 8-bit path. The general idea
here is that we treat all values as sentinels so that they are loaded
from the 'rest'; this requires adjusting the "skip" computation to
pretend that the data portion is 0-byte.
Because we can't compute the number of sentinels (16) in the latency
optimized path correctly, it stays at zero and we use a slightly custom
computation in the return value.
This gains us further 3-7% in decoding throughput on top of latency
optimization.
The vector selection code in the main path *almost* worked as is for
0-bit groups already: it had behavior identical to 8-bit path, so it
would just directly replicate 'rest', so as long as it was zero, we
would decode everything correctly.
For now, to handle this we use a masked load (using a scalar computed
mask). This could also be a vector and based on a table lookup as the
load is harmless; we would need to tweak performance separately.
The pointer adjustments are a little bit similar to the 8-bit case, but
we can no longer get the returned pointer adjusted for free in the
latency-optimized path so we need to spend an extra cmov. This increases
the latency a little bit, however because this now allows us to
completely drop the switch based dispatch and eliminate branch
mispredictions, the end result is still net-positive.
Computing n from hbits is now non-trivial because we need to handle v0
and v1 and they have a different structure, so n is now computed via a
small table lookup instead.
Overall this gains us 3-9% again, on top of the previous 8-bit merge; we
might be able to tune things further still in the future.
To make the diffs easier to follow this keeps the extra indentation,
which will be removed in a separate commit.
This de-indents the AVX512 decoding loop to match the assumed code
style; it's a separate commit so that the history is cleaner in prior
commits. The code itself is unchanged modulo comment tweaks.
Instead of using cmov to handle edge cases, we can mask the shift
instead: in both cases we need to synthesize 0 for cases where n is
larger than the specific cutoff, and because we're building a power of
two we can just mask off the bits that should never be set. This results
in more stable and higher quality codegen, and gains 1-2% extra
depending on data.
This is quite optional, but the new branchless group decoder makes data
that is full of 0/8-bit runs decode slower; in these cases the branch
prediction did a good job previously but now we need to run through a
more complex decoder.
This is mostly a problem in v0, because v1 streams have block-level
literal/zero encodings that eliminate these; however, in v0 some data
decoded faster with the previous branchy AVX-512 implementation.
To work around this without penalizing anything else, we do a quick
check that only triggers on consecutive 4 groups of zero bytes in v0.
Literals still go through a full decode which is a bit less frequent.
We might drop this in the future, but since v0 was the default encoding
up until v1.0 it's better to preserve this for now.
These are now using zero-copy build flags to increase the performance by
~5%.
This makes AVX512 configuration closer to what is likely to be used in
practice: MSVC enables BMI2 automatically, and any configuration that
relies on a broader configuration likely has these enabled.
Instead of using mask_loadu with a zero, use maskz_loadu; these are
equivalent on Clang/GCC, but MSVC does not optimize mask_loadu by itself
so this saves us a (free) vector instruction.
Instead of using a ternary to create the load mask, use (n>>2)-1 which
is -1 iff n==4. This is only marginally better on MSVC/GCC, but Clang
uses a long instruction sequence because it rewrites n==4 in terms of
hbits. That said, the instruction sequence is off the critical path so
in practice this change is benign wrt performance, but should at least
result in more stable codegen across compilers.

@zeux

@zeux

zeux deleted the vtxopt branch

June 12, 2026 15:28

Read the original on github.com ↗