Helium Optimizations Explained
This document covers the optimization aspects of CM85 core MCUs with new Helium instructions, Helium vector processing, and low overhead branch extension. Formally known as Helium technology, the M-Profile Vector Extension (MVE) was first introduced in CM55 with Armv8.1-M architecture. It is also known as Helium MVE extensions.
More details on Helium optimizations can be found in the reference documents provided. Further, practical examples for Helium optimizations on Renesas RA8D1 MCU is shown later in the document.
Vectorized Code
Helium provides single instruction multiple data (SIMD) capability for Cortex-M CPUs. This means that a set of 128-bit registers can be used to hold, for example, 16 separate 8-bit values. Using the same example, a single instruction can operate on each 16 separate 8-bit values independently.
Helium instructions operate on vectors of elements of the same data type. These data types may be floating-point or integer. Integer elements may be signed or unsigned 8-, 16-, 32-bit, while floating-point elements may be single (32-bit) or half precision (16-bit).
The position of an element within a vector register is referred to as a lane. The instruction set is regular and orthogonal, and nearly all instructions perform the same operation in all lanes. This means that most instructions have n-parallel operations where n is the number of lanes that the input vectors are divided into. This is shown in the figure below.
Low Overhead Branch Handling
Repetitive code execution can be optimized by running instructions simultaneously and using special registers. A loop begins with a While Loop Start (WLS) or Do Loop Start (DLS) instruction and finishes with a LE (Loop End) instruction.
The loop start instruction copies the iteration count to the LR register. For WLS, it also performs a check to see whether the iteration count is zero and, if so, causes execution to branch to the end of the loop. DLS is used for a loop where there will always be at least one iteration and so does not need this step. The LE instruction checks LR to see whether another iteration is required and, if so, it branches back to the start.
Further optimizations are possible if the processor can store the start and end addresses of the loop along with the use of vectorized code. This allows the loop to execute only the instructions inside the loop in multiple lanes.
Tail Prediction
An operation performed on a single byte per loop can be vectorized by Helium. For example, a loop that copies 100 bytes can be performed at 16 bytes per iteration because a 128-bit vector can contain 16 bytes of data. The remining 4 bytes (100 - 96 = 4) are copied in a separate block of non-vectorized code at a tail loop. The first loop runs 6 times and copy 96 bytes (6 x 16 = 96) and the second non-vectorized loop runs 4 times to copy the remining bytes.
However, there are now 2 blocks of loops and the code size is increased. Tail prediction solves these problems and allows the execution of loops that process multiple data elements in one single vector loop that are not an exact multiple of the number of elements that fit in a vector. Helium does this by including the TP (Tail Prediction) suffix to the LE instruction creating a new LETP (Loop End with Tail Prediction) instruction.
On the last loop, if the number of remaining elements is less than the size of the vector, the appropriate number of elements at the end of the vector are disabled.
Examples with Renesas RA8D1 MCU
The rest of this document covers practical examples of SIMD vector processing, low overhead branch and tail prediction handling. Test projects are created for Renesas RA8D1 MCU using the following tools:
• e2studio 2024-04.1
• LLVM for Arm v18.1.3
Consider the following memory set and memory copy functions. These types of operations are good examples for vector optimizations. This is because the same orthogonal data type in memory (unsigned 8-bit integers this case) is either set or copied from one location to another.
To fully enable and see Helium optimizations impact, “-O2” optimization level is used. Further, the build flag “-fno-builtin” is used to disable compiler using memset() and memcpy() library code and compiler attribute “noinline” is used to disable compiler inlining the code.
Enable Helium, Auto Vectorization, and Low Overhead Branch Execution
To enable Helium and auto vectorization, make sure to set up the project properties as below.
• CPU: Arm family = “cortex-m85”
• Optimization: Optimization Level = “-O2”
The assembly output of the code is given below with added comments.
// r0 = destination address
// r1 = source address
// r2 = loopcount
cm85_mem_set:
020000fc: push {r7, lr}
020000fe: mov r7, sp
02000100: cmp r2, #0 // compare r2 to 0
02000102: it eq // if-then block with one conditional instruction
02000104: popeq {r7, pc} // if equal, pop stack and return
02000106: vdup.8 q0, r1 // if not, vector duplicate each element of q0
0200010a: dlstp.8 lr, r2 // do loop start with tail prediction and vectorization
0200010e: vstrb.8 q0, [r0], #16 // vector store q0 to address in r0, post increment
02000112: letp lr, 0x200010e // loop end tail prediction
02000116: pop {r7, pc}
cm85_mem_copy:
02000118: push {r7, lr}
0200011a: mov r7, sp
0200011c: cmp r2, #0 // compare r2 to 0
0200011e: it eq // if then block with one conditional instruction
02000120: popeq {r7, pc} // if equal, pop stack and return
02000122: subs r3, r0, r1 // subtract dest address from src address (r3 = r0 – r1)
02000124: cmp r3, #15 // compare to 15
02000126: bhi.n 0x200013a // if higher, branch
02000128: dls lr, r2 // if not, do loop start(non-vectorized, lr = loop count
0200012c: ldrb.w r2, [r1], #1 // load r2 from address in r1, post increment
02000130: strb.w r2, [r0], #1 // store r2 to address in r0, post increment
02000134: le lr, 0x200012c // loop end
02000138: b.n 0x200014a // if end, branch
0200013a: dlstp.8 lr, r2 // do loop start with prediction and vectorization
0200013e: vldrb.u8 q0, [r1], #16 // vector load q0 from address in r1, post increment
02000142: vstrb.8 q0, [r0], #16 // vector store q0 to address in r0, post increment 16
02000146: letp lr, 0x200013e // loop end tail prediction
0200014a: pop {r7, pc}The instruction dlstp.8 lr, r2 sets up a tail prediction loop, where register lr contains the number of elements to be processed, with its initial value coming from r2. The letp lr, 0x200013e instruction branches back to 0x200013e and decrements the number of elements processed in lr. Notice the vldrb and vstrb instructions. These are vectorized operations that load and store 16 bytes of data at each iteration.
The lane width of the operation performed is specified by the instruction (.8 or .32 for example) and the number of elements to calculate the correct number of loop iterations to perform. During the last loop, if the number of remaining elements is less than the vector length, the appropriate number of elements at the end of the vector are disabled. This means that all parts of the memory copy can be executed in parallel (vectorized), and the non-vector tail code can be eliminated. The remining elements are still processed. This makes for simpler (and smaller) code, as well as speeding things up.
Disable Helium
You can disable all Helium optimizations as shown below. To do this, you need to change the CPU settings as:
• CPU: Arm family = “Toolchain default”
• Other CPU options = “-mcpu=Cortex-M85+nomve”
Below is the assembly output with added comments.
// r0 = destination address
// r1 = source address
// r2 = loopcount
cm85_mem_set:
020000fc: push {r7, lr}
020000fe: mov r7, sp
02000100: wls lr, r2, 0x200010e // while loop start
02000104: subs r0, #1 // decrement r0
02000106: strb.w r1, [r0, #1]! // pre increment, store r1 to address in r0
0200010a: le lr, 0x2000106 // loop end
0200010e: pop {r7, pc}
cm85_mem_copy:
02000110: push {r7, lr}
02000112: mov r7, sp
02000114: wls lr, r2, 0x2000128 // while loop start
02000118: subs r0, #1 // decrement r0
0200011a: subs r1, #1 // decrement r1
0200011c: ldrb.w r2, [r1, #1]! // pre increment, load r2 from address in r1
02000120: strb.w r2, [r0, #1]! // pre increment, store r2 to address in r0
02000124: le lr, 0x200011c // loop end
02000128: pop {r7, pc}Both set and copy loops start with wls while loop start instruction and lr has the loop iteration count. The major difference from the Helium enable assembly code is that there are no vectorized instructions here. In both loops, each byte of the memory location is set or read from and copied to another memory location one byte at a time.
Benchmark Results
Table below shows the comparison of Helium enabled vs Helium disabled performance improvement results on RA8D1. Each test was run to set or copy 399,990 bytes.
| Number of Cycles |
|
| |
Operation | Helium | Helium | % Reduction in Number of Cycles with Helium | Helium Improvement Factor |
Memory Set | 800,847 | 175,056 | -78.1% | 4.57 |
Memory Copy | 6,399,870 | 1,250,011 | -80.5% | 5.12 |
A graphical representation of the same results is shown below.
Summary
This example shows one aspect of Helium optimization factors: vectorization. However, Helium optimizations encompass several techniques.
• Helium enabled libraries
• Auto vectorization
• Helium intrinsics
• Assembly code
It begins with the target application. Your “Hello World” program will not have much gain from Helium optimizations. However, mathematical algorithms, DSP calculations, and AI applications are prime targets for Helium optimizations. Main reason for this is that these types of applications can be structured such a way to utilize SIMD capability with vectorized instructions.
Below we overview options for writing Helium enabled code.
Helium-enabled Libraries
When you compile for a Helium-enabled target, a library variant using Helium instructions is selected. When you compile for a target that does not support Helium, a library variant using standard Arm instructions is selected. This means that the same source code can easily be compiled for both Helium-enabled targets and non-Helium-enabled targets.
Examples of Helium-enabled libraries are CMSIS-DSP and CMSIS-NN. LLVM complier used in this example chooses the Helium-enabled CMSIS libraries if the CPU supports it.
Auto Vectorization
Auto-vectorization means allowing the compiler to automatically identify the areas of your code that would benefit from SIMD optimizations. The benefit of using auto-vectorization is that the programmer leaves everything to the compiler. However, compiler might fail to identify a particular part of code can be vectorized.
We have seen examples of auto vectorization in our example. By using “-O2” optimization level, LLVM compiler auto vectorized the set and copy examples.
Helium Intrinsics
Helium intrinsics are function calls that the compiler replaces with appropriate Helium instructions. Using Helium intrinsics gives you direct, low-level access to the exact Helium instructions in your C/C++ code.
The benefit of using intrinsics is that they provide almost as much control as writing assembly language, but leave details like register allocation to the compiler, so that developers can focus on the algorithms.
The disadvantage of using Helium intrinsics is that programming with intrinsics can be more complex than writing standard C/C++ code and requires the programmer to learn about the available Helium intrinsics.
Assembly Code
For very high performance, hand-coded Helium assembly code is an alternative approach for experienced programmers. You can use pure assembly code files, or you can use inline assembly code to embed assembler instructions in your C and C++ code.
The disadvantages are writing on assembly code can be very complex process and require detailed knowledge of the target hardware.
References
Arm Helium Technology, M-Profile Extension (MVE) for Arm Cortex-M Processor, by Jon Marsh ISBN 978-1-911531-23-4
Arm Cortex-M85 Process Devices, Generic User Guide, 101928_0101_07_en