Useful programming tips: Difference between revisions
No edit summary |
GyroVorbis (talk | contribs) (I'm sorry, but the way this is worded is just not quite correct. He's talking about aspects of the processor, but he's making it sound like modern software PARADIGMS and practices don't apply to DC, when in reality we have C++20 and even all of the async, concurrent crap used in multicore working just fine.) |
||
(One intermediate revision by the same user not shown) | |||
Line 1: | Line 1: | ||
The Dreamcast's CPU, model SH7091, is virtually identical to the Renesas SH7750 series of SH4 CPUs. As such, anything that would normally apply to the SuperH-4 architecture applies here. Given that the SH4 is a processor from 1998, many | The Dreamcast's CPU, model SH7091, is virtually identical to the Renesas SH7750 series of SH4 CPUs. As such, anything that would normally apply to the SuperH-4 architecture applies here. Given that the SH4 is a processor from 1998, many hardware features that we have grown accustomed to on more recent x86 and ARM64 processors either do not apply or behave much more primitively on the SH4. For example, SH4 does not have branch prediction, speculative execution, or multiple cores, but it does have a 64-bit floating point unit,<ref>It's predominantly used for single-precision operations: it ''can'' do doubles, but that doesn't mean it's a great idea!</ref> a couple of 128-bit vector operations on 4x packed 32-bit floats,<ref>See fipr, ftrv: http://www.shared-ptr.com/sh_insns.html</ref> a memory management unit (MMU), and a direct memory access controller (DMAC). | ||
In truth, at a very basic level the SH4 architecture is fundamentally not that different from these other, more mainstream architectures (in fact, ARM Thumb is based on SuperH<ref>https://lwn.net/Articles/647636/</ref>), so programming on the SH4 does not require much in the way of "re-learning" how to do things, especially since the Dreamcast uses it in little endian mode exclusively. Mainly, SH4 programming just requires paying a lot more attention to things that modern architectures have made very convenient, like data alignment, cache management, and pipelining. | In truth, at a very basic level the SH4 architecture is fundamentally not that different from these other, more mainstream architectures (in fact, ARM Thumb is based on SuperH<ref>https://lwn.net/Articles/647636/</ref>), so programming on the SH4 does not require much in the way of "re-learning" how to do things, especially since the Dreamcast uses it in little endian mode exclusively. Mainly, SH4 programming just requires paying a lot more attention to things that modern architectures have made very convenient, like data alignment, cache management, and pipelining. | ||
Line 39: | Line 39: | ||
Produces this output (GCC 9.2.0): | Produces this output (GCC 9.2.0): | ||
<syntaxhighlight lang="objdump">420 02e4 862F mov.l r8,@-r15 | |||
421 02e6 4365 mov r4,r5 | |||
422 02e8 962F mov.l r9,@-r15 | |||
423 02ea 0C75 add #12,r5 | |||
424 02ec 224F sts.l pr,@-r15 | |||
425 02ee 4159 mov.l @(4,r4),r9 | |||
426 02f0 4256 mov.l @(8,r4),r6 | |||
427 02f2 12D0 mov.l .L71,r0 | |||
428 02f4 9869 swap.b r9,r9 | |||
429 02f6 6866 swap.b r6,r6 | |||
430 02f8 11D4 mov.l .L72,r4 | |||
431 02fa 6966 swap.w r6,r6 | |||
432 02fc 9969 swap.w r9,r9 | |||
433 02fe 9869 swap.b r9,r9 | |||
434 0300 6868 swap.b r6,r8</syntaxhighlight> | |||
But what if it weren't aligned to 4 bytes? Just this: | But what if it weren't aligned to 4 bytes? Just this: | ||
Line 82: | Line 66: | ||
Accessing the data looks like this, in that case (GCC 9.2.0): | Accessing the data looks like this, in that case (GCC 9.2.0): | ||
<syntaxhighlight lang="objdump">555 03f0 862F mov.l r8,@-r15 | |||
556 03f2 4365 mov r4,r5 | |||
557 03f4 962F mov.l r9,@-r15 | |||
558 03f6 0C75 add #12,r5 | |||
559 03f8 224F sts.l pr,@-r15 | |||
560 03fa 4484 mov.b @(4,r4),r0 | |||
561 03fc 0C63 extu.b r0,r3 | |||
562 03fe 4584 mov.b @(5,r4),r0 | |||
563 0400 0C61 extu.b r0,r1 | |||
564 0402 4684 mov.b @(6,r4),r0 | |||
565 0404 1861 swap.b r1,r1 | |||
566 0406 0C60 extu.b r0,r0 | |||
567 0408 3B21 or r3,r1 | |||
568 040a 2840 shll16 r0 | |||
569 040c 0B21 or r0,r1 | |||
570 040e 4784 mov.b @(7,r4),r0 | |||
571 0410 2840 shll16 r0 | |||
572 0412 1840 shll8 r0 | |||
573 0414 1B20 or r1,r0 | |||
574 0416 0869 swap.b r0,r9 | |||
575 0418 4884 mov.b @(8,r4),r0 | |||
576 041a 9969 swap.w r9,r9 | |||
577 041c 0C63 extu.b r0,r3 | |||
578 041e 4984 mov.b @(9,r4),r0 | |||
579 0420 9869 swap.b r9,r9 | |||
580 0422 0C62 extu.b r0,r2 | |||
581 0424 4A84 mov.b @(10,r4),r0 | |||
582 0426 2862 swap.b r2,r2 | |||
583 0428 0C60 extu.b r0,r0 | |||
584 042a 3B22 or r3,r2 | |||
585 042c 2840 shll16 r0 | |||
586 042e 0B22 or r0,r2 | |||
587 0430 4B84 mov.b @(11,r4),r0 | |||
588 0432 12D4 mov.l .L73,r4 | |||
589 0434 2840 shll16 r0 | |||
590 0436 1840 shll8 r0 | |||
591 0438 2B20 or r2,r0 | |||
592 043a 0860 swap.b r0,r0 | |||
593 043c 0961 swap.w r0,r1</syntaxhighlight> | |||
All of this is just from this simple operation: | All of this is just from this simple operation: |
Latest revision as of 08:21, 14 October 2023
The Dreamcast's CPU, model SH7091, is virtually identical to the Renesas SH7750 series of SH4 CPUs. As such, anything that would normally apply to the SuperH-4 architecture applies here. Given that the SH4 is a processor from 1998, many hardware features that we have grown accustomed to on more recent x86 and ARM64 processors either do not apply or behave much more primitively on the SH4. For example, SH4 does not have branch prediction, speculative execution, or multiple cores, but it does have a 64-bit floating point unit,[1] a couple of 128-bit vector operations on 4x packed 32-bit floats,[2] a memory management unit (MMU), and a direct memory access controller (DMAC).
In truth, at a very basic level the SH4 architecture is fundamentally not that different from these other, more mainstream architectures (in fact, ARM Thumb is based on SuperH[3]), so programming on the SH4 does not require much in the way of "re-learning" how to do things, especially since the Dreamcast uses it in little endian mode exclusively. Mainly, SH4 programming just requires paying a lot more attention to things that modern architectures have made very convenient, like data alignment, cache management, and pipelining.
The following page is a collection of programming tips and tricks to help with optimizing programs to make full use of the SH4 CPU. This is not meant to be a substitute for reading the SH7750 series hardware and software manuals, rather it should be seen more as an additional reference based on experiences working with the chip (and, in fact, certain terms and hardware-specific concepts assume familiarity with those manuals). Both manuals, "SH7750, SH7750S, SH7750R Group User's Manual: Hardware" and "SH-4 Software Manual," can be downloaded from the "Documents" section of any SH7750 series processor's product page on Renesas's website: https://www.renesas.com/eu/en/products/microcontrollers-microprocessors/superh/sh7750/sh7750r.html, and the SH4 C ABI on STMicroelectronics's website, "RM0197: SH-4 generic and C specific application binary interface," is incredibly handy, too--search for RM0197: https://www.st.com/content/st_com/en.html.
A very convenient SuperH assembly reference can be found here, as well: http://www.shared-ptr.com/sh_insns.html.
This page refers to the documents as follows:
- SH7750 Hardware Manual: "SH7750, SH7750S, SH7750R Group User's Manual: Hardware"
- SH7750 Software Manual: "SH-4 Software Manual"
- SH4 C ABI: "RM0197: SH-4 generic and C specific application binary interface"
Alignment
(Refer to: SH7750 Hardware Manual, Section 5 "Exceptions")
The SH4 lives and dies by alignment, and very strictly requires data to be aligned according to its type in memory. Crashes will otherwise ensue.
Take the following example, which defines a packed structure aligned to 4 bytes:
typedef struct __attribute__ ((packed, aligned(4))) {
unsigned char id[4];
unsigned int address;
unsigned int size;
unsigned char data[]; // Flexible array member
} command_t;
Accessing data from this struct is pretty simple, it should just need a simple 4-byte access. This works because the struct is aligned to 4 bytes.
Doing this:
unsigned int cmd_addr = ntohl(command->address);
unsigned int cmd_size = ntohl(command->size);
Produces this output (GCC 9.2.0):
420 02e4 862F mov.l r8,@-r15
421 02e6 4365 mov r4,r5
422 02e8 962F mov.l r9,@-r15
423 02ea 0C75 add #12,r5
424 02ec 224F sts.l pr,@-r15
425 02ee 4159 mov.l @(4,r4),r9
426 02f0 4256 mov.l @(8,r4),r6
427 02f2 12D0 mov.l .L71,r0
428 02f4 9869 swap.b r9,r9
429 02f6 6866 swap.b r6,r6
430 02f8 11D4 mov.l .L72,r4
431 02fa 6966 swap.w r6,r6
432 02fc 9969 swap.w r9,r9
433 02fe 9869 swap.b r9,r9
434 0300 6868 swap.b r6,r8
But what if it weren't aligned to 4 bytes? Just this:
typedef struct __attribute__ ((packed)) {
unsigned char id[4];
unsigned int address;
unsigned int size;
unsigned char data[]; // Flexible array member
} command_t;
Accessing the data looks like this, in that case (GCC 9.2.0):
555 03f0 862F mov.l r8,@-r15
556 03f2 4365 mov r4,r5
557 03f4 962F mov.l r9,@-r15
558 03f6 0C75 add #12,r5
559 03f8 224F sts.l pr,@-r15
560 03fa 4484 mov.b @(4,r4),r0
561 03fc 0C63 extu.b r0,r3
562 03fe 4584 mov.b @(5,r4),r0
563 0400 0C61 extu.b r0,r1
564 0402 4684 mov.b @(6,r4),r0
565 0404 1861 swap.b r1,r1
566 0406 0C60 extu.b r0,r0
567 0408 3B21 or r3,r1
568 040a 2840 shll16 r0
569 040c 0B21 or r0,r1
570 040e 4784 mov.b @(7,r4),r0
571 0410 2840 shll16 r0
572 0412 1840 shll8 r0
573 0414 1B20 or r1,r0
574 0416 0869 swap.b r0,r9
575 0418 4884 mov.b @(8,r4),r0
576 041a 9969 swap.w r9,r9
577 041c 0C63 extu.b r0,r3
578 041e 4984 mov.b @(9,r4),r0
579 0420 9869 swap.b r9,r9
580 0422 0C62 extu.b r0,r2
581 0424 4A84 mov.b @(10,r4),r0
582 0426 2862 swap.b r2,r2
583 0428 0C60 extu.b r0,r0
584 042a 3B22 or r3,r2
585 042c 2840 shll16 r0
586 042e 0B22 or r0,r2
587 0430 4B84 mov.b @(11,r4),r0
588 0432 12D4 mov.l .L73,r4
589 0434 2840 shll16 r0
590 0436 1840 shll8 r0
591 0438 2B20 or r2,r0
592 043a 0860 swap.b r0,r0
593 043c 0961 swap.w r0,r1
All of this is just from this simple operation:
unsigned int cmd_addr = ntohl(command->address);
unsigned int cmd_size = ntohl(command->size);
What's going on here is GCC is avoiding an address alignment crash that would occur from accessing 1-byte-aligned data. This is because struct packing aligns to 1 byte, and GCC needs to do the following process to build an unsigned 4-byte integer from 1-byte accesses:
mov.b, zero-extend
mov.b, zero-extend, shift, add
mov.b, zero-extend, shift, add
mov.b, zero-extend, shift, add
(Note: all the swap instructions come from ntohl, as this code is from a network driver that needs to byte swap data after receiving a network transmission.)
Considering that 1x mov.b takes the same amount of time as 1x mov.l, plus all the other operations that must be done to build the 4-byte data out of 1-byte accesses, it's easy to see how big the performance hit from mismanaging alignment can be!
Cache Management
(Refer to: SH7750 Hardware Manual, Section 4 "Caches" and SH7750 Software Manual, Section 9 "Instruction Descriptions")
Unlike modern processors, where caches are several megabytes in size and can therefore hold entire programs, the SH4 in the Dreamcast only has a 16kB data cache and 8kB instruction cache. Consequently, cache management is very important in order to achieve maximum performance. As is always true of cache optimization, write-back memory mode is required to make much use of it (it's used everywhere by default in KallistiOS and enabled for P0/U0/P3--but not P1--in DreamHAL's startup file).
Half of the data cache can also be used as a form of high-speed RAM (referred to as OCRAM), but in most cases programs should stick to using the full cache size for cache purposes. The SH4 uses a direct-mapped cache, meaning that there is only one cache entry for every 16kB memory chunk (half that when used in OCRAM mode) and cache trashing can happen if trying to do something like copy data from some address to a destination address that is an integer multiple of the cache size away from that address (e.g. source = address offset 8 and destination = address 16kB + 8).
The SH4 provides the following instructions in addition to the two 32-byte "store queues" (SQs) to make efficient use of the cache:
- movca.l: Store register data to cache, if there's a cache miss just allocate a cache block and write to it without first reading that cache block from memory
- ocbp: Purge cache block; write back cache block and invalidate it
- ocbi: Invalidate cache block without writing it back
- ocbwb: Write cache block back to external memory, and keep it in the cache
C Function Register Allocation
(Refer to: SH4 C ABI)
(Note: when using GCC 9.x at various optimization levels, like -O3, it tries its best to coalesce output code into this format wherever it can. Of course, if GCC is able to inline a function, parameter-passing becomes a moot point.)
The SH4 C ABI specifies that 4 integers (r4-r7) and 8 floats (fr4-fr11) can be passed in registers as function call arguments, and that r0-r3 and fr0-fr3 are also call-clobbered. Passing arguments in registers means that functions can take 4 integers and 8 floats without forcing arguments to be pushed on the stack, saving the cycle penalties that would otherwise occur from stack pushes and associated memory accesses. The call-clobbering of r0-r3 and fr0-fr3 means that those can be used as 4 integer local variables and 4 float variables, as well. Additionally, any of these registers not used for parameters can be repurposed as local variables, so if one only needs to pass in 4 floats to a function, one can then define 4 more local variable floats on top of the 4 we get from fr0-fr3 and they will just use the unused registers.
Pipelining and Instruction-Level Parallelism
(Refer to: SH7750 Hardware Manual, Section 8 "Pipelining")
This section is really only relevant when writing assembly. If you write code in a high-level language like C/C++, compilers [try to] take of this for you and there isn't much you can do about it.
Because of the SH4's dual-issue superscalar design, the CPU preloads two instructions at once, and under the right circumstances these instructions can be executed in parallel. The SH4 architecture organizes various instructions into "instruction groups," and parallel execution primarily occurs when two instructions of different groups are issued together. There are a variety of special cases to this rule of thumb, however, and more advanced code can be structured to take advantage of these properties.
For example, if the two instructions are of different groups but have a dependency chain, the second instruction will stall into the next cycle, and there is also the fact that CO group instructions do not parallelize with anything. Conversely, there are special cases like 0-cycle instructions that can execute in parallel despite having dependency chains (e.g. a "mov Rn, Rm" followed by an "add #imm8, Rm"), and MT group instructions that can parallelize with other MT group instructions (unless there's a non-special-case dependency chain).
Appropriate usage of instruction-level parallelism is the only way to achieve >200 MIPS (millions of instructions per second) on a 200MHz SH4.
References
- ↑ It's predominantly used for single-precision operations: it can do doubles, but that doesn't mean it's a great idea!
- ↑ See fipr, ftrv: http://www.shared-ptr.com/sh_insns.html
- ↑ https://lwn.net/Articles/647636/