Skip to content

Lower small table lookups to tbl/pshufb/swizzle - #9581

Open
alexreinking wants to merge 4 commits into
alexreinking/arm-field-shiftfrom
alexreinking/const-lut-shuffle
Open

alexreinking wants to merge 4 commits into
alexreinking/arm-field-shiftfrom
alexreinking/const-lut-shuffle

Conversation

@alexreinking

@alexreinking alexreinking commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Vectorized lookups into small tables currently compile poorly on CPUs: a vectorized mux(idx, {c0, ..., cn-1}) becomes a chain of n compares and selects, and a load from a small Buffer becomes a gather (one scalar load per lane). Most CPUs have a register table-lookup instruction that does either in one instruction per vector: ARM tbl, x86 pshufb/vpermb, WebAssembly i8x16.swizzle. This matters for quantized-weight formats that decode 4-bit codes through a 16-entry int8 codebook (e.g. GGML's mxfp4 and iq4_nl), which GGML's NEON kernels decode with a single vqtbl1q_s8 per 16 codes.

This PR adds a pass, optimize_small_table_lookups (in OptimizeShuffles.{h,cpp}), that turns such lookups into dynamic_shuffle(table, index, 0, n - 1), and lowers dynamic_shuffle on ARM, x86 and WebAssembly.

What is recognized.

  • mux(index, {c0, ..., cn-1}) where every value is a constant. The table is a constant vector. If constant_integer_bounds can't prove the index lies in [0, n - 1], it is clamped (as unsigned) to n - 1, which matches mux's semantics: any out-of-range index, including a negative one, selects the last value.
  • A Load from a Buffer embedded in the pipeline (image defined, no Parameter) whose index is proven to lie in a window of at most n elements inside the buffer's allocation. The table is a dense vector load of that window, and the index is rebased to it.
    • The contents of embedded buffers are not treated as compile-time constants. The JIT passes embedded buffers by reference on every call, so their contents can change between realizations, and the test checks this.
    • Only their shape is relied on: it is fixed at compile time and asserted on entry to the pipeline. So the window load never needs padding and never reads out of bounds. This sidesteps the input-buffer padding question that Support Call::dynamic_shuffle for LUT in SVE2 #9087 has to deal with.
  • If the index range can't be proven, the IR is left alone.

Where. The pass runs at the start of CodeGen_LLVM::compile_func, the same place #9087 runs optimize_shuffles for SVE2 and Hexagon runs its own. The query is a new virtual, int CodeGen_LLVM::max_dynamic_shuffle_table_size(const Type &elem) const: the largest table the target can profitably index, or 0 to disable. The default is 0, so every target without an override keeps today's code. That includes Hexagon, which has its own dynamic_shuffle lowering, and SVE, which #9087 targets. So recognition is generic, and lowering is per target.

Lowering.

Target Condition Instruction Max table
AArch64 NEON, fixed-width vectors tbl1–tbl4 (8- or 16-lane results) 64 bytes
ARMv7 NEON vtbl1–vtbl4 32 bytes
x86 SSE4.1 (SSSE3) pshufb 16 bytes
x86 AVX2 / AVX-512 vpshufb ymm/zmm, table replicated per 128-bit lane 16 bytes
x86 AVX-512 VBMI (avx512_cannonlake) vpermb 64 bytes
WebAssembly wasm_simd128 i8x16.swizzle 16 bytes
  • Only 8-bit element types are handled for now; wider tables are left alone.
  • Wider vectors are split into slices, and the table is zero-padded to whole registers.
  • A shared helper, CodeGen_LLVM::codegen_dynamic_shuffle, does the slicing and padding.
  • CodeGen_LLVM also gets a generic, slow but correct lowering of dynamic_shuffle: one variable extractelement per lane.

Relationship to #9087. That draft PR generalizes optimize_shuffles (the bounded indirect-load → dynamic_shuffle pass) to SVE2 and lowers dynamic_shuffle to SVE2 tbl. This PR is complementary and is designed so the two can converge:

Effect. In a Halide reimplementation of GGML's mxfp4 kernels (apps/ggml on a branch), the codes are now decoded through mux(nibble, kvalues_mxfp4) instead of arithmetic. That now compiles to one tbl per 16 codes, as in GGML's NEON code (vqtbl1q_s8).

mxfp4 · q8_0 kernel (Apple M-series, 1 thread) Before After GGML
vec_dot inner loop, instructions per 2 blocks (64 codes) 85 49 ~51
vec_dot speed relative to GGML, K = 4096 0.37× 0.88× 1×
gemv speed relative to GGML, 4096×4096 n/a 0.93× 1×

Nothing else in the app's kernels changed: rebuilding its ~340 AOT objects with this patch, but without the app change, gave byte-identical objects.

Tests

New tests:

  • correctness_small_table_lookup (JIT) covers:

    • mux with an in-range index, a let-bound index, an unbounded u8 index, and a possibly negative i32 index;
    • embedded buffers with a clamped index, a non-zero min and a sub-range window, and two dimensions;
    • an index not provable at compile time (bounded by a Param), which must be left alone;
    • mutating embedded buffers between realizations.

    It runs these for table sizes 2–65, vector widths 8/16/64, int8/uint8, plus uint16/float, which are not transformed. It also checks the generic dynamic_shuffle fallback.

    Targets: the host, plus host-without-NEON on ARM, plus SSE4.1-only and baseline on x86 hosts, plus no-SIMD on wasm.

  • simd_op_check_{arm,x86,wasm}:

    • ARM: tbl with 1–4 table registers on arm-64, and vtbl.8 with 1/2/4 registers on arm-32;
    • x86: pshufb, vpshufb ymm/zmm, and vpermb;
    • wasm: i8x16.swizzle, replacing a TODO.

Commands run (macOS arm64 host, LLVM 22.1, cmake --preset macOS):

Command Result
ctest -j 8 -R "^(correctness_(mux|gather|aligned_split_mux_phase|many_inlined_selects|multi_way_select|unroll_huge_mux|non_vector_aligned_embeded_buffer|vector_cast|vector_math|simd_op_check_.*|register_shuffle|hexagon_scatter|lazy_convolution|convolution|decompose_vector_shuffle|vector_shuffle|fallback_vscale_sve|sve_codegen_predicated|sme_streaming|small_table_lookup)|performance_clamped_vector_load|generator_aot_shuffler)$" 23 passed; 5 skipped (hexagon_scatter, register_shuffle, and simd_op_check_{hvx,powerpc,riscv}, whose targets this LLVM lacks)
HL_JIT_TARGET=wasm-32-wasmrt-wasm_simd128 ./test/correctness/correctness_small_table_lookup Success
With Homebrew LLVM 23.1 (which has Hexagon/PowerPC/RISC-V): ./test/correctness/correctness_simd_op_check_hvx, ..._powerpc, ..._riscv Success (all three)
pre-commit hooks on commit (clang-format, keep-sorted, codespell, CMake checks) Passed

Not run:

  • The full test suite.
  • x86 execution. On this arm64 host, simd_op_check_x86 checks the generated assembly only, and the correctness test's x86 target variants run only on x86 hosts.
  • ARMv7 execution.

Fixes: no tracking issue.

Breaking changes

None. No public API changes. Generated code changes only on ARM (NEON), x86 (SSE4.1+) and WebAssembly (SIMD128), for vectorized 8-bit lookups into small constant tables and embedded buffers; results are unchanged.

Checklist

  • Tests added or updated (not required for docs, CI config, or typo fixes)
  • Documentation updated (if public API changed) — no public API changes; the new Internal pass and hook are documented in their headers.
  • Python bindings updated (if public API changed) — not applicable, no public API changes.
  • Benchmarks are included here if the change is intended to affect performance. See "Effect" above.
  • Commits include AI attribution where applicable (see Code of Conduct). Written with GitHub Copilot; the commit carries a Co-authored-by: Copilot trailer.

@alexreinking

Copy link
Copy Markdown
Member Author

@copilot Please fix the clang-tidy failure.

Copilot AI commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@copilot Please fix the clang-tidy failure.

Added args.reserve(regs + 1) to resolve the clang-tidy warning. Commit: c7785fa.

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.52036% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.21%. Comparing base (0416644) to head (8441b83).

Files with missing lines Patch % Lines
src/OptimizeShuffles.cpp 88.88% 1 Missing and 12 partials ⚠️
src/CodeGen_LLVM.cpp 79.41% 5 Missing and 2 partials ⚠️
src/Lower.cpp 33.33% 2 Missing and 2 partials ⚠️
src/CodeGen_C.cpp 82.35% 1 Missing and 2 partials ⚠️
src/CodeGen_WebAssembly.cpp 66.66% 0 Missing and 2 partials ⚠️
src/CodeGen_X86.cpp 91.66% 0 Missing and 2 partials ⚠️
src/CodeGen_ARM.cpp 94.11% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                       Coverage Diff                        @@
##           alexreinking/arm-field-shift    #9581      +/-   ##
================================================================
+ Coverage                         73.13%   73.21%   +0.08%     
================================================================
  Files                               263      263              
  Lines                             82586    82810     +224     
  Branches                          20195    20272      +77     
================================================================
+ Hits                              60397    60633     +236     
+ Misses                            15894    15867      -27     
- Partials                           6295     6310      +15     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/CodeGen_ARM.cpp Outdated
const string intrin = target.bits == 64 ?
"llvm.aarch64.neon.tbl" + std::to_string(regs) + (slice_lanes == 8 ? ".v8i8" : ".v16i8") :
"llvm.arm.neon.vtbl" + std::to_string(regs);
value = codegen_dynamic_shuffle(op, regs * reg_lanes, slice_lanes, [&](Value *table, Value *index) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think instead of the lambda arg and simple_call_intrin this should just use the vector slicing already done by call_intrin

Comment thread src/OptimizeShuffles.cpp Outdated
Expr index = mutate(op->index);
ConstantInterval ib = constant_integer_bounds(index, bounds);
if (ib.is_bounded() && ib.min < ib.max && ib.max - ib.min < max_n) {
// The shape of an embedded buffer is fixed at compile time (it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that this block of code should be abstracted (or just documented) as can_prove_index_span_safe_to_load(op, ConstantInterval), and should handle pipeline-internal fixed-size allocations in addition to embedded buffers.

Comment thread src/OptimizeShuffles.cpp Outdated
};

class OptimizeSmallTableLookups : public IRMutator {
const std::function<int(const Type &)> &max_table_size;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It would be great if this could be a lowering pass. If it really does depend on internal codegen state, I think it would clearer if this was a CodeGen_LLVM * pointer that you call the virtual method on, and if that's the case it should live in CodeGen_Internal.cpp/h

@alexreinking
alexreinking force-pushed the alexreinking/const-lut-shuffle branch 2 times, most recently from 2d1f0f9 to 64a0c90 Compare October 23, 2026 00:07
@alexreinking
alexreinking changed the base branch from main to alexreinking/arm-field-shift October 23, 2026 00:07
@alexreinking
alexreinking added this pull request to stack #9597 October 23, 2026 00:08
@alexreinking
alexreinking removed this pull request from stack #9597 October 23, 2026 00:38
@alexreinking
alexreinking force-pushed the alexreinking/const-lut-shuffle branch from 64a0c90 to 7783cf3 Compare October 23, 2026 00:38
@alexreinking
alexreinking added this pull request to stack #9598 October 23, 2026 00:38
@alexreinking
alexreinking force-pushed the alexreinking/const-lut-shuffle branch from 7783cf3 to ee89658 Compare October 23, 2026 03:38
@alexreinking
alexreinking force-pushed the alexreinking/const-lut-shuffle branch from ee89658 to 25cbc80 Compare October 23, 2026 05:16
@alexreinking
alexreinking removed this pull request from stack #9598 October 23, 2026 05:16
@alexreinking
alexreinking added this pull request to stack #9600 October 23, 2026 05:17
@alexreinking
alexreinking force-pushed the alexreinking/const-lut-shuffle branch from 25cbc80 to c7324f6 Compare October 23, 2026 14:43
@alexreinking
alexreinking removed this pull request from stack #9600 October 23, 2026 14:43
@alexreinking
alexreinking added this pull request to stack #9605 October 23, 2026 14:43
alexreinking and others added 4 commits October 24, 2026 10:14
Add a pass, optimize_small_table_lookups, run from CodeGen_LLVM::compile_func,
that turns vectorized lookups into small tables into
dynamic_shuffle(table, index, 0, n - 1) calls when the target says it can do
them profitably, via a new CodeGen_LLVM::max_dynamic_shuffle_table_size hook.
Two sources are recognized:

- mux(index, {c0, ..., cn-1}) with constant values. The table becomes a
  constant vector; if the index isn't provably in range it is clamped to
  n - 1 as an unsigned value, matching mux semantics.
- Loads from embedded Buffers whose index is provably within a window of at
  most n elements inside the buffer's allocation. The table is a dense load of
  that window. Buffer contents are not assumed to be constant (JIT passes
  embedded buffers by reference), only their shape, which is asserted on
  entry to the pipeline, so there is no padding or out-of-bounds read.

dynamic_shuffle is then lowered to:
- AArch64 NEON tbl1-tbl4 (tables up to 64 bytes), ARMv7 vtbl1-vtbl4 (up to
  32 bytes).
- x86 pshufb (SSE4.1 targets; AVX2/AVX-512 forms with the table replicated per
  128-bit lane) for tables up to 16 bytes, and AVX-512 VBMI vpermb for tables
  up to 64 bytes.
- WebAssembly i8x16.swizzle for tables up to 16 bytes.
Other targets (including SVE, and Hexagon, which has its own
dynamic_shuffle lowering) keep their existing codegen. CodeGen_LLVM also gains
a generic (slow but correct) lowering of dynamic_shuffle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: alexreinking <169273+alexreinking@users.noreply.github.com>
…nd-trip

The round-trip JIT-compiles a deserialized copy of the pipeline, whose
embedded buffers are copies, so updating the originals is not visible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move table lookup optimization into target-driven lowering, support fixed-size internal allocations, and use call_intrin vector slicing. Preserve C++ emission and extend regression coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@alexreinking
alexreinking force-pushed the alexreinking/const-lut-shuffle branch from c7324f6 to 8441b83 Compare October 24, 2026 15:43
@alexreinking
alexreinking removed this pull request from stack #9605 October 24, 2026 15:44
@alexreinking
alexreinking added this pull request to stack #9609 October 24, 2026 15:45
@alexreinking alexreinking added the skip_buildbots Do not run buildbots on this PR. Must add before opening PR as we scan labels immediately. label Oct 24, 2026

This branch has not been deployed

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

Labels

skip_buildbots Do not run buildbots on this PR. Must add before opening PR as we scan labels immediately.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants