added 10 commits
June 9, 2026 20:18Vertex 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.
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 deleted the vtxopt branch
June 12, 2026 15:28