《不择手段的优化秘诀(Playdate 的 C 语言版)》
Dirty Optimization Secrets (C for Playdate)

原始链接: https://devforum.play.date/t/dirty-optimization-secrets-c-for-playdate/23011

对于 Playdate 的 Cortex-M7,假设 CPU 速度较快,但内存访问开销较高,因此大量使用寄存器的代码可能比看似更“聪明”的代码表现更好。由于指令缓存很小,应优先编写紧凑的热循环;`-Os` 可能优于 `-O3`。可以将低频路径移至其他位置,并使用段属性和自定义链接器映射文件,让核心函数连续排列。用 `nm` 检查符号地址,并验证代码大小和布局。 将频繁访问的数据放在栈上,或者把栈中很少使用的低地址区域保留为持久 DTCM,并使用栈保护值加以保护。热代码也可以从专用的 `.itcm` 段复制到 TCM 中,但必须保留兼容 Thumb 的地址,正确使用短调用和长调用,避免 PIC,刷新指令缓存,并逐步测试。 性能可能因无关的代码改动而变化,原因包括 32 字节缓存行布局以及可能的分支预测器别名冲突。可通过链接器对齐来隔离源文件,将热函数和热循环按 32 字节对齐,并尝试让代码在约 1 KB 边界附近前后偏移。基准测试应使用高分辨率帧计时。对于指针密集的间接访问工作负载,预取可能会有所帮助,但目前尚未观察到普遍的性能提升。

Hacker News 用户讨论了《Dirty Optimization Secrets(用 C 开发 Playdate)》,这是一份讲解如何榨取受限硬件性能的指南。一位 commenter 分享了索尼 PlayStation 时代极端的体素渲染优化技巧:把热点代码塞进体积很小的指令缓存,将函数迁移到高速暂存存储器,利用 GTE 计算来掩盖内存访问造成的停顿,采用简化的投影公式,以及直接写入 GPU 寄存器。这些例子进一步说明,代码布局、内存行为和指令集的 quirks 都能显著影响性能,在低功耗设备上尤其如此。 其他人则认为,这种解谜式的优化正是 Playdate 开发的乐趣所在,并将它与 RISC 和 CISC 的发展历史进行比较。他们指出,代码密度和内存带宽至今仍然十分重要。讨论还涉及 8 位和 16 位系统上以汇编为主的开发方式,以及围绕 Playdate 的 145 美元售价、做工、软件生态和封闭的软件模式展开的争论;评论者将这些方面与价格低廉的开发板及其他游戏主机进行了比较。
相关文章

原文

These are some advanced optimization tricks I've discovered while @stonerl and I have been cooking the full-speed gameboy emulator. These are not the typical tips for optimizing games you can find elsewhere, so if you are not familiar with existing techniques then these may not be as useful for you.

These kinds of optimization techniques are most likely to be helpful if you're trying to get very performant code for something like an emulator, a large simulation (such as a factorio-like), a 3D renderer, a codec, and so on.

1. Fast CPU, slow Cache

This comes from research @StiNKz did, and you can find their results on discord here. Your model of the playdate's CPU should be that it is very fast CPU but is slow to access memory, especially memory not in the cache. This means if your code is operating primarily on registers and not loading lots of data, it will likely go blazingly fast, even if it has lots of loops and branch mis-prediction.

2. Instruction Cache

-O3 is not necessarily fastest; -Os might be much better. Playdate has a small instruction cache (Rev A's is only 4 kilobytes), so if your core code -- say, your emulator interpreter loop, your fragment renderer, your physics engine -- does not fit within 4 kilobytes (apparently 16 kb for Rev B?) then it will likely not be as performant as code that's more compressed (even if more compressed code requires many more CPU operations.) The first thing I did to improve performance for gameboy emulation was to squeeze the 20 kb emulator core into just 2 kb by removing a giant switch table and expressing it using much less code with just a few branches. (And using -Os.) The behaviour was identical, but it was much faster as a result.

Now, that's just your core code that needs to fit in this 4 kb cache. You can take rare operations (e.g. opcodes that almost never occur, or edge cases that are very unlikely in your simulation) and put those elsewhere.

Ideally, your 4 kb core code should all be placed at contiguous addresses by the linker. How can you achieve this? Use __attribute__((section(".text.<your-section-name-here>"))) on each core function to ensure the linker places them in one contiguous section. You can do slightly more advanced tricks with a linker map (see next section).

3. Custom Linker Map

Copy link_map.ld from the C_API/buildsupport folder into your project, then add this line to your makefile before you include common.mk:

override LDSCRIPT=./link_map.ld

This lets you use a custom linker script portably. If you haven't edited a linker script before (as most people haven't), don't worry, there's not much to it. You can specify that all the code from certain files be put in a certain spots like so (assuming your .o files are placed in build/):


/* custom code section (i.e. with __attribute__((section(".text.blah"))) */
*(.text.blah)

/* everything from foo.c */
build/foo.o(.text)
build/foo.o(.text.*)

/* Align to 32 bytes */
. = ALIGN(32);

/* the code not explicity mentioned above */
*(.text)
*(.text.*)

Example linker script.

4. Inspect symbols

Keep this command handy:

nm Source/pdex.elf | sort > syms.txt

This produces a list of all symbols (functions, variables, etc.) and their associated addresses. You can check how big your core code is quite easily and see whether or not it fits in icache.

Example output (portion):

00019361 t write_cart_ram_file /* <-- this is a function*/
00019420 t $d
00019440 t $t
00019441 t gb_error
000194dc t $d
00019500 t $t
00019501 t PGB_GameScene_menu
00019638 t $d
0001965c t $t
00019a04 t $d
00019a30 t $t
00019a31 T PGB_LibraryConfirmModal
00019a44 t $d
00019a50 t $t
00019a51 t gb_save_to_disk.isra.0
00019aa8 t $d
00019ac0 t $t
00019ac1 T __gb_write_vram
00019b10 t $t
00019b11 t PGB_GameScene_free
00019b9c t $d
00019bc0 t $t
00019bc1 T PGB_GameScene_new

5. DTCM acceleration

There is a small region of memory called tightly-coupled memory (TCM) which is faster than normal memory. The stack sits in the TCM area, so if you have a stack-allocated object it will generally be faster than something on the heap or a static variable.

Indeed, if you're going to operate on a certain struct for a while, it can be worth memcpy-ing it onto the stack, operating on it there, and then memcpy-ing it back. @RPDev implemented this for PlayGB and got a big perf boost.

// slow --
long_operation(&global_var);

// faster --
some_type copy;
memcpy(&copy, &global_var, sizeof(global_var));
long_operation(&copy);
memcpy(&global_var, &copy, sizeof(global_var));

But we can do even better! Why not let the variable live on the stack permanently instead of on the heap? Then we can skip two memcpys. Well there's a reason we can't do this -- we don't have control over main, so we can't keep anything permanently on the stack. There's a workaround though -- let's store it on the other end of the stack (that is, the low address, the part we should never reach lest we risk a stack overflow.) You can use __builtin_frame_address(0) in your eventHandler on kEventInit in order to determine the high-address end of the stack, then subtract something less than 10 kb (I found 0x2180 has been safe) in order to reach the low-end of the stack. If this feature is eventually added, that is a better way to find the low-address region of the stack.

If you store something in this low region, which I'll call the dtcm_mempool, it should persist from update to update. Place a canary at the start and end of the dtcm_mempool and check them at least once per update to make sure that a stack overflow hasn't corrupted the dtcm_mempool.

By the way, there are other parts of memory that are also in the TCM region, such as the playdate's framebuffer. You can put data there for efficient access if you don't have enough stack space (though obviously you'll need to deal with the fact that this data will actually appear on-screen if you render it!)

6. ITCM acceleration

Data isn't the only thing that we can put on the stack (or in the dtcm_mempool) -- we can put code there too. It's unclear whether this results in any acceleration for Rev B, though more research is needed. But on Rev A it's the secret sauce for efficient gameboy emulation.

Now, the Playdate won't automatically put your code into the TCM region, so you'll need to copy it there from .text manually. Define this macro in C to mark functions as belonging to the itcm region:

#define _itcm __attribute__((section(".itcm"))) __attribute__((short_call))

You can place _itcm before a function definition to mark it as belonging to itcm.

Next, in the linker map, put this:

__itcm_start = .;
*(.itcm)
__itcm_end = .

This causes two symbols to be exposed to C, one at the start of your .itcm code and one at the end. Using this, you can memcpy the entire .itcm code to wherever you like (including into TCM). Important: make sure that the address you copy to is congruent to the original address mod 2, since the Cortex M7 uses the lowest bit of a function pointer to indicate whether the code is Thumb or ARM. Also important: for this same reason, you can't memcpy from a function address directly, since it the function address is actually going to be 1 byte after the start of the function. ARM is weird.

extern char __itcm_start, __itcm_end;

char dst_itcm_region[(uintptr_t)__itcm_end - (uintptr_t)__itcm_start];
memcpy(&dst_itcm_region, &__itcm_start, &__itcm_end - &__itcm_start);

// call the ITCM copy of the function
((fn_ptr_t)((void*)some_itcm_fn - __itcm_start + dst_itcm_region))(args...);

Considerations:

  • DON'T enable -fPIC, since rather counterintuitively the relocation table breaks work when the code is relocated like this.
  • use the shortcall attribute for .itcm code, as in the macro above, since when one itcm function calls another itcm function it needs to do a relative jump (otherwise it could jump to its original non-relocated location).
  • Any function outside of the .itcm region which your .itcm code can invoke must be marked with __attribute__((longcall)), which forces the code to do an absolute jump (otherwise it will do a relative jump into somewhere unknown.)
  • flush the icache after modifying/copying around code. There's a function for this in the playdate API.

This will likely crash when you first try it if you don't get it exactly right. Therefore, start small with this -- try just copying a single function which does nothing except returns a number, for instance, and work your way up from there. Be very careful when you port a large function over to itcm, since it might call other functions by shortcall (and so it will most surely crash).

7. Performance Lottery

As you modify your code, you'll notice without apparent pattern that some builds are slower and some builds are faster, sometimes significantly so (like 50% faster.) There are many causes for this, but there are two causes in particular that I have identified as especially prominent: cache misalignment and branch prediction tables.

Cache Misalignment

The cache line size is 32 bytes, meaning that your application loads 32 bytes of instructions at a time. If there are two functions of size 16 bytes each, and they're right next to each other, and the first ones address is divisible by 32 exactly, then loading one of those functions will cause the other function to enter the cache as well. On the flip side, if one function is 32 bytes long, but its starting address isn't divisible by 32, then it will straddle the boundary of two cache lines (and that's bad).

How does this cause a performance lottery effect? Well, it means adding one function whose size is not divisible by 32 somewhere early in the code causes every other function to shift down, and their addresses might no longer be congruent to their old addresses mod 32. At this point it's a toss-up whether it's faster or slower.

How to minimize this effect? Every now and then in your code, force something to be aligned to 32-bytes. You can do this in C with __attribute__((aligned(32))), or from your linker map with . = ALIGN(32);. The advantage of doing this from your linker map is that you can specify an offset from 32 bytes manually, e.g. . = ALIGN(32); . += 0xC. You should look at the output of nm (section 4) from your most recent fast build (the last build which did well on the performance lottery) to figure out what manual offset to use here. I like to put . = ALIGN(32) before each source file and section in my linker map to ensure that they are all quarantined from each other in terms of cache line.

By the way, you can use the compiler option -falign-loops=32 to ensure that loops get aligned to 32-bytes in code, which could also help.

Branch prediction tables

The details of how branch prediction works on the Cortex M7 CPU are not public knowledge. By my estimate -- and I could be completely wrong about this! -- there is a table of prediction values for 1024 unique instruction addresses. In layman's terms, this means that if two if statements are a multiple of 1024 bytes apart from each other, then the branch predictor will treat them the same, causing lots of mispredictions. Therefore, in your linker map, sprinkle in a handful of . = ALIGN(1024); . += n; for varying values of n in the range 0-1024. (Better yet, choose n based on the address of the first symbol following it in the last good build.) Now, that is 4+ kilobytes of wasted space, so don't do many of these.

I might be wrong about branch prediction being what's causing this second-order performance lottery, but regardless, ALIGN(1024) trick has helped, beyond just the fact that 1024 is divisible by 32.

I found the performance lottery has almost disappeared since implementing these tricks.

8. Prefetching

I haven't observed any improvement in performance using prefetching, but I might just not be doing it smartly enough. @FReDs72 describes a case where prefetching helped in this discord thread. Performance improvements are likely quite fine-grained when it comes to prefetching, so perhaps measure your frame time with the high-precision timer instead of the normal FPS gauge.

In theory, prefetching should help in cases where you have indirection. For instance, if iterating through a list of pointers (int** list), you might benefit from (a) iterating through the whole list in advance and prefetching each list[n], or (b) prefetching list[i+1] before visiting list[i].

联系我们 contact @ memedata.com