说真的,大代码模型到底是干嘛用的?

Lobsters Hottest 新闻

摘要

本文探讨了GCC大代码模型(mcmodel=large)的局限性,特别是它无法处理带有32位重定位的线程局部存储(TLS),因此尽管其声称用于大型二进制文件,实际上却无法用于真正的大型二进制文件。

<p><a href="https://lobste.rs/s/snetpk/seriously_what_is_large_code_model_even">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/27 19:48

# 说真的,大型代码模型到底有什么用? 来源:https://fzakaria.com/2026/07/26/seriously-what-is-the-large-code-model-even-for 我一直在致力于让*巨型二进制文件*在`$DAYJOB$`成为可能。你本应能够依赖的救命稻草之一就是大型代码模型(`mcmodel=large`),因为它对重定位的大小和距离不做任何假设。 > **大型代码模型**:大型代码模型不对节(sections)的地址和大小做任何假设。\[引用 (https://gitlab.com/x86-psABIs/x86-64-ABI)\] 在之前的[一篇文章](https://fzakaria.com/2026/03/27/does-anyone-actually-use-the-large-code-model)中,我记录了我如何遇到简单的性能瓶颈,这让我相信代码模型在实践中很大程度上是理论性的。在那些情况下,修复方法是显而易见的且相对较小;然而,它们的缺失暗示了没有人使用该代码模型,因为没有它们,性能损失是难以接受的。 我暗示了大型代码模型在其他一些方面仍然存在不足,但我还没有完全理解这些失败模式的痛苦。我在底部留下了一个小预告,提到线程本地存储(`\。tdata`/`\。tbss`)是“有趣的失败模式”之一。 结果发现情况比我想象的更糟。编译器为TLS发出的指令序列是*天生*32位的,而`-mcmodel=large`无法将其替换为其他形式。🤦🏻♂️ 直白地说,尽管`mcmodel=large`宣称的目标是构建复杂的大型二进制文件,但实际上它无法做到这一点。 ## § (https://fzakaria.com/2026/07/26/seriously-what-is-the-large-code-model-even-for#how-do-you-even-make-a-2gib-binary) 如何生成一个>2GiB的二进制文件? 这整个研究过程中最烦人的部分就是制造一个测试对象。在`\。text`中发出2GiB的*真正的*指令是非常痛苦的,你必须生成并汇编数十亿条指令,而且目标文件会非常庞大。除此之外,当你考虑代码是否是位置无关的、是否使用过程链接表(PLT)、TLS、GOT以及各编译器在其默认链接器脚本中如何布局和排序节的细微差别时,问题空间的基数还会增加。 但重定位溢出并不关乎磁盘上有多少字节,而是关乎引用与其目标之间的**虚拟地址距离**。所以我们有几个选择。 未初始化的全局变量位于`\。bss`中,这是一个`NOBITS`节:它占用虚拟地址空间,但**磁盘上占用零字节**(与我之前在[创建巨型假文件](https://fzakaria.com/2026/02/11/creating-massively-huge-fake-files-and-binaries)中写到的稀疏文件技巧相同)。因此,几千个1MiB的数组几乎可以免费提供数千兆字节的地址空间。 我们可以通过以下脚本轻松地合成生成这一点。 `` # N个1MiB的全局变量,加上一个访问每个变量的函数 # 这样编译器就会为每个数组生成一个重定位。 import sys N, CHUNK = int(sys.argv[1]), 1024 * 1024 for i in range(N): print(f"char s{i}[{CHUNK}];") print("long sum(void){ long a = 0;") for i in range(N): print(f" a += s{i}[0];") print(" return a; }") print("int main(void){ return (int)sum(); }") `` `` $ python3 gen.py 4096 > huge.c $ gcc -c huge.c -o huge.o # 4 GiB地址空间,288 KiB磁盘空间 $ du -h huge.o 288K huge.o `` 用(默认的)小型代码模型链接它,它会在你预期的位置崩溃,即2GiB有符号32位边界处: `` $ gcc -no-pie huge.o -o huge huge.c:(.text+0x780f): relocation truncated to fit: R_X86_64_PC32 against symbol `s2048' ... `` `s2048`位于`2048 * 1MiB` = 恰好2GiB处。 使用`-mcmodel=large`重新编译*相同的源代码*,它就能干净地链接: `` $ gcc -c -mcmodel=large huge.c -o huge_large.o $ gcc -no-pie -mcmodel=large huge_large.o -o huge_large `` 大型模型完成了它的工作:它将32位的`R_X86_64_PC32`引用替换为64位的`R_X86_64_GOTOFF64`序列:一个`movabs`(64位偏移量)加上一个`add`。 `\。bss`技巧对于测试链接器很好,但有点不令人满意且过于合成。我经常想模拟当重定位遍历一个大的`\。text`段时的失败情况。我们可以利用汇编器的`\。fill`指令来重复指令。我们可以生成一个由N个函数组成的微小源文件,这些函数间隔1MiB,之间填充`NOP`指令,再加上一个调度函数来`call`每个函数。这要求每个`call`都有一个重定位,并且对超过2GiB标记的函数的调用会溢出。🔥 生成器:`gen_realtext.py` `` # N个微小函数,间隔1MiB,加上一个调用每个函数的调度函数。 # .fill类似于memset,因此汇编器无需代码生成即可发出字节。 import sys N, CHUNK = int(sys.argv[1]), 1024 * 1024 # 例如 4096 -> ~4 GiB的.text print(".text") print(".globl main") print("main:") for i in range(N): print(f" call f{i}") # 每个函数一个重定位 print(" ret") for i in range(N): print(f".globl f{i}") print(f"f{i}:") print(" ret") print(f" .fill {CHUNK}, 1, 0x90") # 1 MiB真正的NOP字节 -> 真正的.text `` ## § (https://fzakaria.com/2026/07/26/seriously-what-is-the-large-code-model-even-for#a-quick-primer-on-tls-access-models) TLS访问模型快速入门 现在让我们做完全相同的事情,但使用`__thread`。 当你访问一个线程局部变量时,编译器不会只是加载一个地址,而是发出四种*访问模型*之一,从最快/最不灵活到最慢/最灵活: **局部执行 (LE)**变量位于主执行文件自己的TLS块中。线程指针(`%fs`)的偏移量是一个链接时常量,直接作为32位立即数嵌入到指令中;使用`R_X86_64_TPOFF32`。**初始执行 (IE)**变量位于启动时加载的模块中。64位线程指针偏移量存储在一个**GOT槽**中,代码通过RIP相对引用加载它;使用`R_X86_64_GOTTPOFF`到达该槽,`R_X86_64_TPOFF64`填充该槽。**通用动态 (GD)**/**局部动态 (LD)**是`dlopen`加载模块的完全通用情况,调用`__tls_get_addr`;使用`R_X86_64_TLSGD`和`R_X86_64_TLSLD`。注意模式:到达线程指针的值可以是64位的(即它位于GOT槽中),但**参与TLS访问的每条指令都使用32位字段**。`TPOFF32`、`GOTTPOFF`、`TLSGD`、`TLSLD`都是32位的。 生成器:`gen_tls.py` `` # 与gen.py相同,但每个数组都是线程局部变量(__thread),因此它们位于 # .tbss中,每次访问都会发出TLS重定位而不是普通数据重定位。 import sys N, CHUNK = int(sys.argv[1]), 1024 * 1024 # 例如 4096 -> ~4 GiB的.tbss for i in range(N): print(f"__thread char t{i}[{CHUNK}];") print("long sum(void){ long a = 0;") for i in range(N): print(f" a += t{i}[0];") print(" return a; }") print("int main(void){ return (int)sum(); }") `` `` $ python3 gen_tls.py 4096 > tls_huge.c # 像gen.py一样,但每个数组都是`__thread` $ gcc -c -mcmodel=large tls_huge.c -o tls_huge.o $ du -h tls_huge.o 288K tls_huge.o `` 4GiB的`\。tbss`,磁盘上仍然很小。现在看看**大型**代码模型生成的重定位: `` $ readelf -r tls_huge.o | awk '{print $3}' | grep R_X86 | sort | uniq -c 1 R_X86_64_GOTOFF64 2 R_X86_64_GOTPC64 2 R_X86_64_PC32 4096 R_X86_64_TPOFF32 `` 每一个TLS访问都是`R_X86_64_TPOFF32`,它是**32位**的,即使在使用`-mcmodel=large`时也是如此。🫣 `` $ gcc -no-pie -mcmodel=large tls_huge.o -o tls_huge tls_huge.c:(.text+0x25): relocation truncated to fit: R_X86_64_TPOFF32 against symbol `t0' defined in .tbss ... tls_huge.c:(.text+0x36): relocation truncated to fit: R_X86_64_TPOFF32 against symbol `t1' ... `` 刚刚还能愉快地链接4GiB普通`\。bss`的**完全相同的大型代码模型**,却在4GiB的`\。tbss`上**失败了**。 这也不是GNU的怪癖。LLVM(`clang`和`lld`)做了完全一样的事情,不过`lld`的诊断信息更友好一些,会打印实际的偏移量和它需要容纳的窗口: `` $ clang -c -mcmodel=large tls_huge.c -o tls_huge.o $ clang -no-pie -fuse-ld=lld -mcmodel=large tls_huge.o -o tls_huge ld.lld: error: tls_huge.o:(function sum: .ltext+0x17): relocation R_X86_64_TPOFF32 out of range: -4294967296 is not in [-2147483648, 2147483647]; references 't0' `` `-4294967296`正是`-4GiB`,`t0`位于TLS块的最底部,而`[-2147483648, 2147483647]`是`TPOFF32`必须容纳的有符号32位窗口。 `lld`甚至将大型模型代码放在了一个`\。ltext`节中。 ## § (https://fzakaria.com/2026/07/26/seriously-what-is-the-large-code-model-even-for#why-the-large-model-is-powerless-here) 为什么大型模型在这里无能为力 一开始我有点困惑:`R_X86_64_TPOFF64`**是存在的**。为什么编译器不使用它,特别是在`mcmodel=large`的情况下? 一个重定位类型**与它修补的特定字段绑定**,并且由编译器发出的代码序列决定。以下是`-mcmodel=large`发出的**局部执行**序列: `` 4: 64 48 8b 04 25 00 00 mov %fs:0x0,%rax d: 48 05 00 00 00 00 add $0x0,%rax R_X86_64_TPOFF32 x 13: 8b 00 mov (%rax),%eax `` 偏移量通过`add $imm32, %rax`应用。该指令的x86-64编码**只有32位立即数字段**。它没有64位形式。能够物理上修补该字段的唯一重定位是32位的:`TPOFF32`。 那么`TPOFF64`到底在哪里使用?它根本不在代码(`\。text`)中。它作为动态重定位存在于用于**初始执行**的**GOT槽**中。代码端只有32位的`GOTTPOFF`来*指向*该槽: `` 4: 48 8b 0d 00 00 00 00 mov 0x0(%rip),%rcx R_X86_64_GOTTPOFF y b: 64 48 8b 04 25 00 00 mov %fs:0x0,%rax 14: 48 01 c8 add %rcx,%rax 17: 8b 00 mov (%rax),%eax `` `TPOFF64`修补GOT中8字节(64位)的*数据字*。而*到达*该字的指令(`GOTTPOFF`)是32位PC相对重定位。所以即使是初始执行,在链中也有一个32位环节:GOT槽必须位于代码的2GiB范围内。😭 ## § (https://fzakaria.com/2026/07/26/seriously-what-is-the-large-code-model-even-for#it-fails-for-exactly-the-mode-its-meant-for) 它恰恰在它本应工作的模式下失败了 **局部执行**是你希望用于大型静态链接可执行文件自身线程局部变量的模式。它是快速路径:线程指针偏移量是一个链接时常量,因此访问是直接的`add $imm32`,没有内存加载,没有GOT,没有间接引用。不幸的是,`TPOFF32`仅限于32位,并且会溢出。 我认为它也是最有可能*需要*大型二进制文件的模式,因为大型静态可执行文件使用它来处理自身的TLS。 如果我们回退到较慢的模式呢?不幸的是,每种模式也有一个32位字段: - **初始执行**`GOTTPOFF`是32位RIP相对寻址到GOT槽。 - **通用动态**`TLSGD`是32位RIP相对。 使用`GOTTPOFF`尤其令人困惑。大型代码模型在其他地方都使用64位到达GOT:`GOTPC64`用于基址,`GOT64`用于槽,因此遗漏似乎令人惊讶。64位的机制就在那里。TLS就是不用它。🥲 ## § (https://fzakaria.com/2026/07/26/seriously-what-is-the-large-code-model-even-for#this-is-a-hole-in-the-abi-not-a-compiler-bug) 这是ABI中的漏洞,而不是编译器错误 深入研究后,这不仅仅是GCC或LLVM中的一个错误。再看一下`clang`在`-mcmodel=large`下为单个**局部执行**访问发出的内容: `` 4: movq %fs:0x0, %rax d: addq $0x0, %rax # R_X86_64_TPOFF32 x 13: movl (%rax), %eax `` 这是一个普通的`addq $imm32, %rax`。没有什么能阻止clang改用64位形式: `` movabs $x@tpoff, %rdx # 在movabs立即数中的TPOFF64 addq %fs:0, %rdx `` `TPOFF64`可以修补那个立即数,就像`GOTOFF64`已经修补普通数据的`movabs`立即数一样。 奇怪的是,**重定位类型并不是缺失的部分。**缺失的是[x86-64 psABI](https://gitlab.com/x86-psABIs/x86-64-ABI)中的*代码序列*,它使用64位TLS偏移量作为指令立即数。ABI从未定义过大型代码模型的TLS访问模型,因此`TPOFF64`/`DTPOFF64`只出现在GOT槽中,从未出现在代码中,而没有工具链能发出规范未描述的内容。 大型代码模型“存在”,但它实际上并不能交付任意大的二进制文件。对于线程本地存储,它做不到,因为规范从未定义如何做到。 这是我要努力使巨型二进制文件成为可能的又一个原因。如果你想跟进,讨论在[x86-64-abi](https://groups.google.com/g/x86-64-abi/c/hz28LNnlBEc/m/J211uZASAgAJ)谷歌群组中,我发布了一份RFC,我们还在[LLVM巨型二进制文件工作组](https://discourse.llvm.org/t/rfc-forming-a-massive-binaries-working-group-in-lld/91031)开始了工作,并每月举行会议。

相似文章

超智能小型模型与超高效率大型模型之争

Reddit r/LocalLLaMA

本文探讨了功能强大的本地化小型大语言模型与为效率优化的大型模型之间的争议,并通过MiniCPM5 2B和MoE Qwen3.6 35B在CPU上的运行表现进行对比分析。

一种基于MLIR的大型语言模型编译方法

arXiv cs.CL

本文提出了一种基于MLIR的大型语言模型编译方法,通过两种自定义方言(TopOp和TpuOp)将模型从框架无关的语义逐层降低为硬件专用指令,并针对自回归推理阶段(预填充、预填充KV和解码)引入三阶段静态编译。