Lower PReLU, pixel_shuffle/unshuffle, and step>1 slice to GPU-clean forms - #1090
john-rocky wants to merge 2 commits into
Conversation
outtanames
left a comment
There was a problem hiding this comment.
Nice work! Just a handful of nits but overall g2g.
|
Thanks for the review! All four comments are addressed in 54c5137 — details in the inline replies. Summary: the pixel_shuffle JAX lowering was indeed dead and is deleted; the slice staticness check now treats |
|
@john-rocky mind taking care of the conflicts on |
54c5137 to
030ef31
Compare
|
Done — rebased onto main. The |
030ef31 to
b1b3b20
Compare
|
Rebased on I converted the Real-ESRGAN general-x4v3 image model twice — stock
One correction in the same edit. Re-measured on current |
…orms - aten._prelu_kernel: decompose to relu(x) - w * relu(-x) instead of where(x > 0, x, w * x). The where form legalizes to GREATER + SELECT, which the TFLite GPU delegate rejects; the relu form is numerically identical (max|diff| = 0) and legalizes to RELU/MUL/SUB. - aten.pixel_shuffle / aten.pixel_unshuffle: decompose through rank-4 reshape/transpose steps (interleaving one spatial axis at a time) instead of a rank-6 reshape + permute. GPU delegates cap tensor rank at 4, so the rank-6 form forces these models off the GPU. - aten.slice / aten.slice_copy: emit jax.lax.slice for static step>1 slices, which legalizes to a single STRIDED_SLICE, instead of the torchax jnp-indexing lowering whose strided path emits gather (TFLite GATHER_ND, rejected by the GPU delegate). Step-1 and dynamic slices keep the torchax lowering unchanged. Verified via Interpreter op sets and numerics: nn.PReLU nets now convert to CONV_2D/RELU/MUL/SUB (was GREATER+SELECT), nn.PixelShuffle(2) to rank-4 RESHAPE/TRANSPOSE (was rank 6), x[:, :, ::2, ::2] and the YOLOX Focus stem to STRIDED_SLICE (was GATHER_ND); all exact vs eager. export_hf smoke (tiny llama): float CPU parity corr 1.0.
…s, extend tests - Delete the JAX pixel_shuffle lowering. aten.pixel_shuffle is in torch's core_aten_decompositions(), which seeds the decomp tables in fx_infra/decomp.py, and exported_program_to_mlir always runs pre_lower_decomp (can_skip=False) before lowering, so the op never reaches the lowering (already true before this PR). - Treat export_utils.IR_DYNAMIC as dynamic in the strided-slice staticness check. The sentinel is a plain int, so isinstance alone let dynamic dims through; and since jax.lax.slice needs concrete bounds for every dimension (limit_indices covers the full shape), require the whole shape to be static. Dynamic inputs fall back to the torchax lowering, same as before this PR. - Add 3D (no-batch) pixel_shuffle/unshuffle cases and sys.maxsize slice-bound cases, and a test_gpu_clean_lowering regression test asserting the lowered modules contain no stablehlo.gather/compare/ select (TFLite GATHER_ND/GREATER/SELECT) and no tensor of rank > 4.
b1b3b20 to
a778e51
Compare
Summary
Follow-up to #1079, same theme: several common ops decompose into primitives the GPU delegate (ML Drift /
LITERT_CL) rejects, even though a GPU-clean equivalent exists. This PR fixes the three worst offenders in litert-torch's decomposition/lowering layer. Op sets read from the converted.tfliteviaInterpreter._get_ops_details()onmainHEAD (7781284):nn.PReLU)GREATER+SELECT(+MUL) — both GPU-rejectedRELU/MUL/SUBmax|diff| = 0.0RESHAPE(>4D cap)RESHAPE/TRANSPOSEmax|diff| = 0.0x[..., ::2, ::2])GATHER_ND— GPU-rejected †STRIDED_SLICEbuiltinmax|diff| = 0.0† JAX-version dependent — see Correction on the slice half (item 3) below.
Each of these today forces a model off the GPU or into onnx2tf / private converter patches: PReLU blocks restoration/SR nets (e.g. Real-ESRGAN), the rank-6 pixel-shuffle blocks sub-pixel upsampler heads, and
GATHER_NDblocks YOLOX/YOLOv5 Focus stems and ViT patch-embed-via-slicing.What this PR does
1.
aten._prelu_kerneldecomposition override (_decomp_registry.py)The core aten decomposition is
where(x > 0, x, w * x)→GREATER+SELECT. Overridden with the numerically identical (including NaN propagation) relu formrelu(x) - w * relu(-x), following the existing_safe_softmaxoverride precedent in the same file.2.
aten.pixel_shuffle/aten.pixel_unshuffledecomposition overrides (_decomp_registry.py)The default decomposition materializes a rank-6 intermediate (
reshape → 6D permute → reshape), but GPU delegates cap tensor rank at 4. The override folds batch and channel into one dimension and interleaves one spatial axis at a time, so every intermediate stays rank 4: 3 reshapes + 2 transposes, exact for any batch shape and upscale factor (also handles the 3D no-batch case).3.
aten.slice/aten.slice_copystrided lowering (_jax_lowerings/lowerings.py)torchax implements slice with jnp basic indexing; JAX's strided path lowers to
lax.gather→ TFLiteGATHER_ND. For fully staticstep > 1slices we now emitjax.lax.slice, which the converter legalizes to a singleSTRIDED_SLICE. Step-1 and dynamic slices keep the torchax lowering unchanged, so generative-path slicing (masks, KV caches, dynamic dims) is unaffected.Verification
ai_edge_litertInterpreter → compare vs eager): all new cases exact (max|diff| = 0.0), max tensor rank ≤ 4, noGATHER_ND/GREATER/SELECT. Covers: pixel_shuffle r∈{1,2,3} incl. 3D input, pixel_unshuffle r∈{2,3}, PReLU per-channel + scalar alpha, strided slice with negative/None/sys.maxsizebounds, and step-1 regression cases.cat([x[...,::2,::2], x[...,1::2,::2], x[...,::2,1::2], x[...,1::2,1::2]], 1)): nowCONCATENATION+STRIDED_SLICEonly (wasGATHER_ND).export_hfend-to-end on a small Llama; float CPU parity vs HF referencecorr = 1.000000, top-1 100%,max|diff| = 7.5e-08. KV-cache tests green.test_core_aten_ops.py(pixel_shuffle/unshuffle, prelu, strided slice/slice_copy).On-device measurement (Pixel 8a, LITERT_CL / ML Drift)
I converted the Real-ESRGAN general-x4v3 super-resolution model twice — once on pristine
mainHEAD (b66af07), once with this PR — and loaded each on a Pixel 8a through the LiteRTCompiledModelGPU path. It is SRVGGNetCompact with the official weights: 33 ×nn.PReLUandnn.PixelShuffle(4), stock modules, no model-side rewrite. The two.tflitefiles areidentical except for the litert-torch version.
LITERT_CL)CompiledModel(Accelerator.GPU)mainHEAD (before)Replacing 134 out of 141 node(s) … 3 partitionsCreatefailsReplacing 176 out of 176 node(s) … 1 partitionBefore, the delegate rejects the pixel-shuffle tail outright:
With the GPU accelerator alone and no CPU fallback,
CompiledModel::Createreturns an error andthe model never loads. With CPU fallback enabled it loads, but splits into 3 partitions and runs
the tail on XNNPACK. After this PR no op is rejected, the graph is one partition, and
CompiledModel(Accelerator.GPU)succeeds.Latency is best-of-10 with the output read back inside the timed loop (enqueue + compute +
readback), CPU fallback enabled so that both sides actually run: 69.2 ms → 35.7 ms.
The result is unchanged. Each device GPU output matches its host CPU
.tflitereference atcorr = 0.994894before and0.994892after (fp16 GPU compute vs fp32 CPU), and the two deviceoutputs agree with each other at
corr = 0.999998,max|diff| = 2.4e-04.Converter-side, on the same two files: before 141 nodes,
GREATER×33 +SELECT×33, max tensorrank 6, 2 tensors >4D; after 176 nodes, no GPU-hostile op, max rank 4, 0 tensors >4D.
.tflite-vs-eagermax|diff|is identical on both sides (4.86e-06). The 35 extra nodes areexactly the cost of the two rewrites: +1 op per PReLU (33) and +2 for the rank-4 pixel shuffle.
This is the model in my open super-resolution sample, google-ai-edge/litert-samples#170. To reach
the GPU today, that sample's conversion script rewrites PReLU into
relu(x) − a·relu(−x)andPixelShuffle into a zero-stuffed transposed conv, by hand, model-side. With this PR the stock
graph is already GPU-clean, so neither rewrite is needed.
Correction on the slice half (item 3)
Re-measured on current
mainHEAD: the step>1-slice →GATHER_NDbehaviour is JAX-versiondependent, which my original table (
main@7781284) did not say.x[:, :, ::2, ::2]onmainb66af07GATHER_ND×2STRIDED_SLICE×2STRIDED_SLICE×2STRIDED_SLICE×2 (no change)The cause is upstream of both litert-torch and the TFLite converter — JAX's own lowering of
strided basic indexing changed:
requirements.txtleavesjax[cpu]unpinned, so a fresh py3.12/3.13 install lands on jax 0.11.xand will not reproduce that row. A py3.10 install still does: 0.6.2 is the newest jax that
publishes a py3.10 wheel. The YOLOX Focus stem behaves the same way —
GATHER_ND×6 on jax 0.6.2,STRIDED_SLICE×6 on jax 0.11.1.The PReLU and pixel_shuffle/unshuffle rows reproduce identically on both.
So item 3 earns its place only on the older-JAX installs; on a current JAX it is a no-op, with
identical op sets and counts before and after. I'm happy to split it into its own PR, or drop it,
if you'd rather keep this one to the two decomposition overrides.
Notes / follow-up
The native
PRELUandDEPTH_TO_SPACE/SPACE_TO_DEPTHTFLite builtins would be even better targets, but emitting them needs converter-side pattern or composite support (no stablehlo→TFL legalization currently produces them) — out of litert-torch's decomposition layer, so this PR takes the ops to GPU-clean ≤4D elementwise/reshape forms instead, andSTRIDED_SLICE(where a direct legalization does exist) to the native builtin.Together with #1079 (attention view chains within rank 4), this clears the decomposition-side GPU blockers for the common vision-model patterns.