[RFC] A New Sign Extension Elimination Pass

Context

Many redundant sext and zext instructions still escape LLVM IR middle end optimizations. This is because it’s quite complex to remove them. Other passes such as InstCombine and the DAGCombiner are local and don’t work across PHI cycles. Our approach removes many of them by using fillType aware analysis together with a greatest fixpoint analysis. The function pass achieves code size reductions up to 7.1% in the Oz pipeline on an x86 machine using the LLVM test suite.

An earlier version of this work was presented at EuroLLVM 2024 in the talk
Sign Extension Optimizations inside LLVM.

The EuroLLVM talk focused mostly on the fillType analysis, corresponding to the
first component described below. Since then, the work has been updated and extended into
the complete four-component design proposed in this RFC.

I also should note that this is one of my first RFCs, so I’d appreciate some feedback.

Proposed Solution

I’ve implemented a holistic solution that resulted in an sign/zero extension removal pass named WIA in short. The pass is partly based on the paper Widening Integer Arithmetic, Redwine and Ramsey, CC 2004. The paper is referenced in the CodeGen documentation of LLVM.

Design

I’ve split the design into 4 classes.

The first class WideningIntegerArithmetic creates solutions that hold all possible
widths for a given instruction I. By width we mean the bitwidth of I’s type.
A solution holds some other data too, such as the fillType of I. This allows us to drop many extensions.

We use the CC paper here, its type system and the meaning of the fillTypes, to create the necessary solutions for each Instruction.

A fillType indicates for an operand what it produces and accepts in its upper bits.

An operand has a fillType only if it is widenable, i.e, if by applying the operator to a wider value, we can simulate the operator applied to narrow values.

wide value: [ fillType bits ][ original bits ]

SIGN: [ b b b b b b b ][ b . . . . . . . ]
ZEROS: [ 0 0 0 0 0 0 0 ][ . . . . . . . . ]
ANYTHING: [ ? ? ? ? ? ? ? ? ][ . . . . . . . . ]

For example since the OR instruction can produce a ZERO when operands are zero-filled, 8-bit OR can be implemented as a 16-bit OR.

x narrow: 1010 0101
y narrow: 0001 1000
result narrow: 1011 1101

zext(x) to i16: 0000 0000 1010 0101
zext(y) to i16: 0000 0000 0001 1000
-------------------
or result i16: 0000 0000 1011 1101

This clearly explains why the low bits do not change, if we have a fillType. Then we can simulate the narrow operator with a wider one.

In terms of using a different simpler definition for the fillType, we know that operators represent the truth of mathematics. So, if we use the KnownBits of LLVM and see that the upper bits of the operands of the OR are ZERO, then we can infer that OR will produce zero in it’s upper bits.

Almost all integer instructions are widenable. The specific list is derived from the CC paper itself. Regarding overflowing operators, they are widenable only if we have the nuw and nsw flag and the fillType on both operands is ZERO if nuw or SIGN if nsw.

At the end each Instruction has some solutions to bigger widths, and we can use them if we want to promote to a bigger type.

The second class ExtRewritePlanner decides if it’s profitable to remove an extension. It tries to build a wide or narrow expression to apply either widening or narrowing.

Suppose we have a sext i8 %a to i32.

We have two options:

  1. We can either narrow all the relevant instructions around the extension to i8 making the extension redundant. This is possible if the extended bits are not used downstream by any of the users of the extension.

  2. We can try to promote all the relevant instructions to i32 allowing us to drop the extension. This is explained more below.

By relevant instructions we mean what is reachable through operands and users around the extension to form the mathematical expression that the code tries to calculate.

The first option is an ad-hoc analysis similar to the MIR pass that exists in the backend of RISC-V named RISCVOptWInstrs.cpp.

The second option is legal if the source value (%a of the extension) has a solution at the extension’s destination width with a fillType that satisfies the extension semantics. For zext we need fillType of ZEROS, and for sext we need a fillType of SIGN. Also, all the relevant instructions must have a fillType that is needed around the expression. This forms a lattice of fillTypes on what operands can provide as a fillType and what fillType an instruction can produce to its users. This is applicable much more often than the first option because in most cases the extended bits are demanded (used downstream).

The third class PromoteConstraintSolver is used only when we are widening and checks if the promoted expression graph is legal based on the fillType constraints of the promoted expression graph. It uses a greatest fixpoint analysis that is needed especially when we have PHI cycles, because one solution depends on another and the reverse; a simple approach doesn’t work here. Conceptually, it uses a finite lattice where each node maps to set of possible promote states (width, fillType). The solver starts at the top element, containing all candidate fillTypes at the promoted width, and iteratively removes states that violate legal operand/user constraints.
If the fixpoint does not end up to an empty state set (each node retains at least one valid state) and we have found a matching fill for the extension we want to remove (ZERO for zext SIGN for sext) then the promoted graph is legal.

The fourth class, named Rebuilder just does all the necessary rewrites to remove the extension, and it tries to preserve nuw/nsw flags (future work).

Examples

I will focus on examples that can’t be easily fixed by other passes and show the real value of this pass.

To keep the text small, I’ll show C code on the examples and minimal IR diffs if needed.

Example 1

The following example is from the LLVM Test Suite benchmark
MultiSource/Benchmarks/MiBench/automotive-susan/automotive-susan.test, in susan.c:

int n=0;
for (i=5;i<y_size-5;i++) {
  for (j=5;j<x_size-5;j++) {
    x = r[i*x_size+j];
    if (x>0) {
      if ( /* condition */ ) {
        corner_list[n].info=0;
        corner_list[n].x=j;
        corner_list[n].y=i;
        corner_list[n].dx=cgx[i*x_size+j];
        corner_list[n].dy=cgy[i*x_size+j];
        corner_list[n].I=in[i*x_size+j];
        n++;
        if(n==MAX_CORNERS) exit(1);
      }
    }
  }
}

On this C code, WIA promotes n through nested-loop conditional increments, removing repeated sexts feeding corner_list[n]. Other passes such as InstCombine, TypePromotion, CodeGenPrepare and DAGCombiner cannot handle those cases and are not easily fixed.

A small IR diff looks like this.

- %n.02190.us = phi i32 [ 0, ... ], [ %n.2.us, ... ]
- %n.12186.us = phi i32 [ %n.02190.us, ... ], [ %n.2.us, ... ]
- %idxprom1343.us = sext i32 %n.12186.us to i64
- %arrayidx1344.us = getelementptr inbounds [24 x i8], ptr %corner_list, i64 %idxprom1343.us
- %inc1370.us = add nsw i32 %n.12186.us, 1
- %cmp1371.us = icmp eq i32 %inc1370.us, 15000
- %n.2.us = phi i32 [ %inc1370.us, %if.then1342.us ], [ %n.12186.us, ... ]

+ %n.02190.us = phi i64 [ 0, ... ], [ %n.2.us, ... ]
+ %n.12186.us = phi i64 [ %n.02190.us, ... ], [ %n.2.us, ... ]
+ %arrayidx1344.us = getelementptr inbounds [24 x i8], ptr %corner_list, i64 %n.12186.us
+ %inc1370.us = add nsw i64 %n.12186.us, 1
+ %cmp1371.us = icmp eq i64 %inc1370.us, 15000
+ %n.2.us = phi i64 [ %inc1370.us, %if.then1342.us ], [ %n.12186.us, ... ]

Example 2

The following example is from the LLVM Test Suite benchmark
MultiSource/Applications/d, in parse.c:

int i, j, head = 0, tail = 0;
SNode **q = MALLOC(ERROR_RECOVERY_QUEUE_SIZE * sizeof(SNode*));

for (sn = p->snode_hash.last_all; sn; sn = sn->all_next) {
  if (tail < ERROR_RECOVERY_QUEUE_SIZE - 1)
    q[tail++] = sn;
}

while (tail > head) {
  sn = q[head++];
  ...
  if (tail < ERROR_RECOVERY_QUEUE_SIZE - 1)
    q[tail++] = sn->zns.v[i]->sns.v[j];
}

WIA promotes the loop carried tail from i32 to i64, through a loop removing repeated sext used for q[tail++]. This is not easy for other passes such as InstCombine or DAGCombiner because the rewrite requires changing multiple loop-carried PHIs, conditional increments and GEP Indices.

A small IR diff looks like this

- %tail.0480.i = phi i32 [ %tail.1.i, %for.inc.i ], [ 0, ... ]
- %cmp21.i = icmp slt i32 %tail.0480.i, 9999
- %inc23.i = add nsw i32 %tail.0480.i, 1
- %idxprom.i = sext i32 %tail.0480.i to i64
- %arrayidx.i = getelementptr inbounds [8 x i8], ptr %call.i196, i64 %idxprom.i
- %tail.1.i = phi i32 [ %inc23.i, %if.then22.i ], [ %tail.0480.i, ... ]
- %tail.3.lcssa.i = phi i32 [ %tail.2495.i, ... ], [ %tail.6.i, ... ]
- %39 = sext i32 %tail.3.lcssa.i to i64
- %cmp29.i = icmp slt i64 %indvars.iv.next526.i, %39

+ %tail.0480.i = phi i64 [ %tail.1.i, %for.inc.i ], [ 0, ... ]
+ %cmp21.i = icmp slt i64 %tail.0480.i, 9999
+ %inc23.i = add nsw i64 %tail.0480.i, 1
+ %arrayidx.i = getelementptr inbounds [8 x i8], ptr %call.i196, i64 %tail.0480.i
+ %tail.1.i = phi i64 [ %inc23.i, %if.then22.i ], [ %tail.0480.i, ... ]
+ %tail.3.lcssa.i = phi i64 [ %tail.2495.i, ... ], [ %tail.6.i, ... ]
+ %cmp29.i = icmp slt i64 %indvars.iv.next526.i, %tail.3.lcssa.i

Example 3

The following example is from the LLVM Test Suite benchmark MultiSource/Benchmarks/Ptrdist/bc, in scan.c:

ch = charMap[*p];
while (check[base[state] + ch] != state) {
  state = fallback[state];
  if (state >= 144)
   ch = meta[ch];
}
state = next[base[state] + ch];

On this C code, WIA promotes ch PHIs used in GEP indices. This moves the extensions to PHI incoming values and removes repeated extensions from the loop. Other passes such as InstCombine and the DAGCombiner cannot easily handle those cases.

charMap = internal constant [256 x i8] ...
@meta = internal constant [53 x i8] ...
@check = internal constant [247 x i16] ...
@next = internal constant [247 x i16] ...

%ch.ptr = getelementptr i8, ptr @charMap, i64 %char
%ch = load i8, ptr %ch.ptr
+ %ch64 = sext i8 %ch to i64

- %c = phi i8 [ %ch, %entry ], [ 1, %loop ]
- %c64 = sext i8 %c to i64
+ %c64 = phi i64 [ %ch64, %entry ], [ 1, %loop ]

%check.idx = add i64 %base64, %c64
%check.ptr = getelementptr [2 x i8], ptr @check, i64 %check.idx

%meta.ptr = getelementptr i8, ptr @meta, i64 %c64
%meta.ch = load i8, ptr %meta.ptr
+ %meta64 = zext i8 %meta.ch to i64

- %c.next = phi i8 [ %meta.ch, %fallback ], [ %c, %retry ]
- %c.next64 = sext i8 %c.next to i64
+ %c.next64 = phi i64 [ %meta64, %fallback ], [ %c64, %retry ]

%next.idx = add i64 %base.next64, %c.next64
%next.ptr = getelementptr [2 x i8], ptr @next, i64 %next.idx

base64 and base.next64 are coming from other GEPS but due to lack of space here, I won’t show them.

Why Existing Passes are Insufficient

  • The fillType analysis itself doesn’t make much sense to be integrated into passes, because it is quite heavy.

  • InstCombine is local and doesn’t support cyclic phis. This requires either SCCs or a greatest fixpoint analysis to be implemented and this is not trivial.

  • DAGCombiner works inside phi boundaries and it doesn’t work across PHI nodes.

  • TypePromotion works for a specific use case around icmps and it also supports promoting phis only with zext, but the zext must be inside a loop. It’s not trivial to add sext support in the way the pass is designed.

  • Handling extensions inside Loops with CodeGenPrepare isn’t trivial too, it requires many things: promoting through recurrences, legality of the promotions, a better profitability model and maybe more things.

Code-size results

I run the llvm test suite with the Oz pipeline, on a X86 machine.
Several benchmarks show code size reductions up to 7.1% while the largest observed regression is limited to 0.4%

Metric Change
Geomean 0.000067%
Largest improvement 7.1%
Largest regression 0.4%
Benchmarks evaluated 2515

Results for the motivating examples

Source example LLVM Test Suite benchmark WIA size Upstream size Bytes saved Reduction
susan.c MiBench-automotive-susan/susan 27,992 28,088 96 B 0.3%
parse.c Applications/d 87,551 87903 352 B 0.4%
scan.c /Ptrdist/bc 46,164 46,372 208 B 0.45%

LLVM Test suite code-size improvements

The table includes some llvm-test-suite benchmarks ran at the Oz optimization level using llvm test-suite.

Benchmark august4_wia_v1 august4_upstream_v1 Bytes Saved Diff
MultiSource/FreeBench/mason 2,253.00 2,413.00 160 7.1%
GCC-C-execute-930603-3 280.00 288.00 8 2.9%
GCC-C-execute-20021010-2 301.00 309.00 8 2.7%
GCC-C-execute-pr52209 270.00 272.00 2 0.7%
GCC-C-execute-pr70586 412.00 414.00 2 0.5%
GCC-C-execute-pr66556 426.00 428.00 2 0.5%
bc 46,164.00 46,372.00 208 0.5%
telecomm-gsm 44,508.00 44,700.00 192 0.4%
toast 44,524.00 44,716.00 192 0.4%
agrep 56,173.00 56,413.00 240 0.4%
make_dparser 87,551.00 87,903.00 352 0.4%
is 4,615.00 4,631.00 16 0.3%
automotive-susan 27,992.00 28,088.00 96 0.3%
GCC-C-execute-mode-dependent-address 1,033.00 1,036.00 3 0.3%
XSBench 12,925.00 12,957.00 32 0.2%
paq8p 100,910.00 101,150.00 240 0.2%
minisat 21,518.00 21,566.00 48 0.2%
Shootout-strcat 533.00 534.00 1 0.2%
IRSmk 8,824.00 8,840.00 16 0.2%
ReedSolomon 10,195.00 10,211.00 16 0.2%
sqlite3 488,863.00 489,551.00 688 0.1%
lemon 47,534.00 47,566.00 32 0.1%
office-ispell 51,206.00 51,238.00 32 0.1%
lencod 749,896.00 750,280.00 384 0.1%
hbd 32,559.00 32,575.00 16 0.0%
SPASS 505,554.00 505,794.00 240 0.0%
pairlocalalign 455,394.00 455,602.00 208 0.0%

Lower values are better. Complete results, including other changes can be provided if requested.

All Regressions (.text)

LLVM Test Suite regressions

The following table shows all the code-size regressions observed with WIA at the
-Oz level. The code size regressions occur mainly because of promotions to the i64 type. On X86 wider operations can require larger encodings. The current X86 TTI cost model does not account for instruction encoding size. I spent some time trying to fix the regressions, below are all the remaining unfixed ones.

Benchmark august4_wia_v1 august4_upstream_v1 Bytes Added Diff
GCC-C-execute-20000703-1 312.00 312.00 0 0.0%
CLAMR 732,720.00 732,704.00 16 -0.0%
consumer-typeset 442,830.00 442,814.00 16 -0.0%
kc 396,253.00 396,237.00 16 -0.0%
Packing-dbl 98,896.00 98,880.00 16 -0.0%
gs 152,897.00 152,849.00 48 -0.0%
PENNANT 112,537.00 112,489.00 48 -0.0%
mybison 66,391.00 66,359.00 32 -0.0%
clamscan 538,706.00 538,370.00 336 -0.1%
HPCCG 23,075.00 23,043.00 32 -0.1%
Obsequi 36,470.00 36,406.00 64 -0.2%
miniAMR 63,009.00 62,897.00 112 -0.2%
AMGmk 17,950.00 17,918.00 32 -0.2%
archie 17,563.00 17,531.00 32 -0.2%
city 7,752.00 7,736.00 16 -0.2%
hexxagon 15,098.00 15,066.00 32 -0.2%
CoMD 40,281.00 40,185.00 96 -0.2%
fhourstones 6,914.00 6,897.00 17 -0.2%
PathFinder 22,946.00 22,882.00 64 -0.3%
ocean 4,370.00 4,354.00 16 -0.4%

Compile Time

In order to measure the compile time I used an idle machine doing 10 paired runs of the CTMark with the pass enabled and not enabled. The pass reduced the total CTMark compilation time by approximately 0.1%.

Proposal

This RFC proposes a new middle end optimization for the Oz pipeline, that achieves code size reductions up to 7.1% with some regressions and no compilation time impact. Future work can include fixing the remaining regressions, increasing the coverage of the pass by using OptimizationRemarks, evaluating runtime impact on the Os pipeline, and enabling the pass for additional targets such as AArch64 or RISC-V.

I look forward to the feedback from the community.

@petrhosek

2 Likes

Given the evaluation results, I’m skeptical this is worth the maintenance/review/compile time costs. A geomean of 0.000067% is incredibly small and you could likely do much better with some minor tuning of other passes (e.g., enable GVN sink/hoist by default).

The evaluation doesn’t really reveal the full story though. llvm-test-suite isn’t really a great benchmark for most things. Collecting numbers on large real world applications where people care about code size (like web browsers, even clang) I think would be a much better evaluation.

Indeed, geomean isn’t the right metric. The regressions seem to be in applications that don’t really care about code size, so would hardly call -Oz. I don’t know what mason is, but the GCC results aren’t bad. Chromium, Clang, Linux are good choices. Automotive/embedded applications/benchmarks also.

This is surprising, probably within standard deviation?

@smithp35 @statham-arm would this be interesting for embedded Arm?

I don’t have a good overview of the kinds of thing people do in embedded Arm code in particular. (The embedded code I’ve seen is highly selection-biased towards whatever provokes compiler bugs!)

But I do notice that all of the motivating examples in the thread are dealing with the casual use of int for array indices, which probably ought to have been size_t or ssize_t or something like that. (Presumably because the code was originally written in the assumption that all the world’s 32-bit, and later recompiled for 64-bit without going through and correcting all the integer types.)

If that’s the usual cause of excessive sign extension, then I’d expect it to be less of a problem in the first place on AArch32 (embedded or otherwise), where int and size_t are the same size anyway.

The only freely available embedded code-size benchmark that I know of is csibe

Code-size can also be run on the individual objects in libraries, so it could be useful to try an -Oz build of baremetal llvm-libc. I’ve used the samples from Zephyr RTOS to give compare clang and GCC code-size for realistic embedded programs before Samples and Demos — Zephyr Project Documentation

At Arm we also use CMSIS GitHub - ARM-software/CMSIS_6: CMSIS version 6 (successor of CMSIS_5) · GitHub to measure code-size but I expect that is Arm only.

Outside of embedded. There are some people with very large applications (I’ve seen LLVM dev presentations on Android Apps) that might use -Oz.

On the “is it worth the complexity/review time?” That will be difficult to tell from the RFC alone. May be worth a draft PR, or a pointer to your own LLVM fork.

I think mason is llvm-test-suite/MultiSource/Benchmarks/FreeBench/mason/mason.c at main · llvm/llvm-test-suite · GitHub . As far as I can tell the GCC results are also solely from the torture test suite (e.g., llvm-test-suite/SingleSource/Regression/C/gcc-c-torture/execute/930603-1.c at main · llvm/llvm-test-suite · GitHub ) rather than from a compilation of gcc itself.

I might be wrong, but I think there’s also the cases where the compiler is down beyond C semantics, “legalizing” operands by blindly promoting producers’ types “because they’re still native register sized”.

Yeah, I got that far and looked at the code, and I have no clue what it’s meant to be doing. :rofl:

Chromium might be in the sweet spot, as it’s used in Android and desktops alike, and Clang has a long history with them.

Right, not useful. Self-hosting Clang could be a quick way to get some good results, too. Compare all three stages.

Thanks for the feedback @boomanaiden154-1.

The compile cost for the pass is nonexistent.
As for the other two, it will probably come down to whether the community wants the function pass.

After I solve all the regressions because they are affecting clang, llvm-libc, and probably chromium too, I’ll do the evaluation and post results.

Also, this was an initial evaluation. The design of the function pass is new.

About GVN sink/hoist, it solves a different problem. That would be of interest in the near future too.

That’s correct; I was very careful when taking the measurements.

I’ve noticed some improvements on AArch64 too; I can run experiments again in a few weeks. The pass can remove extensions in other cases. I never performed any experiments on AArch32; I’ll check if I have a machine available.

Thanks for all the information, Peter.
I’ll look into it.

I think the more important question at this point is whether the impact justifies adding the pass. The implementation is organized into four main classes; it can be split into smaller, indepedently reviewable changes if reviewability is a concern.