Skip to content

proposal: align wasm and embedded binary sizes with TinyGo through whole-program optimization #2679

Description

@cpunion

目标与结论

设计修订(2026-10-03):以对齐 TinyGo 的最终产物大小为完成目标,并补齐实现架构、分项预算、收敛阶段及验收。 达到中期体积、补上几个优化开关,均不表示本提案完成。第 4、6、7 节为本轮实施设计;第 1~3、5、8、9 节保留历史实测与估算,表中的 LLGo 大小不是最新 main 的重测结果。

本轮核对的 LLGo main 为 8c0026e3a2dfa0aba49caf8e3869b8277f85165e。W32/WAMR 默认线程后端、基础 DWARF/debug 入口和浏览器调试前端已经合入;-deadcodedrop 仍是 dev 功能,-size 的 Wasm/ELF 度量缺陷、Emscripten 最终链接降级到 O2、存活方法/类型元数据的精细裁剪仍需推进。第 2.3、8.5 节中的历史失败现已逐项复核,修复 PR 和仍未完成的目标支持见下表。历史测量不改称最新 main 的匹配基线。

以 TinyGo 的 cprintf、println、fmtprintf 产物大小为目标,优化 LLGo 的 WebAssembly 和嵌入式构建。本文汇总 2026-09-25~26 的源码调研、实际编译/运行、符号归因和消融实验,并给出尚未实现项目的收益估算。

这是 #2480 的后续实施提案,也承接 #1662 的 TinyGo 调研;架构遵循 #2632:保留 patched Binaryen、浏览器 Emscripten C/C++ 兼容性、WASI threads/WAMR、GC/EH 和多 worker 支持。旧单线程 WASI 的数据用于诊断,不再为它建立新的产品路线。非 wasm 的 pthread/thread backend 不因本提案更改调度模型;W32 当前的 goroutine/pthread 对应关系也不在本体积优化提案中改为 M/G 分离。

主要设计决策:

  • 编译器和运行时继续提供完整的已支持能力;对可证明封闭的应用,按实际可达的操作和外部入口决定链接哪些实现、表和初始化代码。不能用“没有 import reflect”、函数名黑名单或 Hello 文本判断是否可删。
  • 编译期统一计算函数、类型/方法、全局初始化、runtime 子系统和宿主边界的需求;LLVM、ThinLTO、元数据生成、Asyncify 和 JS 导出共用同一根集合。优化后的保留原因必须可查询。
  • 目标同时约束最终 wasm、浏览器全部运行资源及 embedded Flash;DWARF 的部署口径单列,默认 runtime PCLN/类型数据不能漏算。外置、共享、压缩和延迟载入属于部署优化,不自动算作总字节消除。
  • 原有单项估算只用于排序。本轮用 TinyGo 比值和 Code/Data/容器预算驱动收敛,不能将重叠的单项上限相加后宣布达标。未知差距继续进入下一轮分析。
  • 四组连贯贡献覆盖度量/构建、编译器与元数据、runtime/输出、目标集成和最终收敛;默认启用以正确性和 PR CI 实测为前提。

历史实验的主要发现:

  • W32 threads 的最终优化曾受 Clang 隐式管线和 PATH 影响。PATH 中有 pinned wasm-opt 时,Clang 已运行 -Oz;此前 40,297 / 40,196 / 431,062 B 是未执行该步骤的历史基线收益,不能在已优化构建上重复计入。
  • W32 threads 的 fmtprintf 经 DCE + 最终 Oz 后仍为 1,165,321 B,其中 Code 524,085 B、Data 638,665 B,距 TinyGo 84,833 B 尚需减少 1,080,488 B。决定性问题是保留的功能闭包和数据布局。
  • 小程序主要支付 runtime/libc 固定成本;fmt 还支付反射、方法、初始化和 PCLN 成本。浏览器胶水、embedded formatter 应分别处理。
  • 最小 host API 导出实验中 JS 减少 50,828 / 50,828 / 55,467 B,wasm 不变;取消 ESP32-C3 强制 float 根的诊断减少 16,520 / 13,854 B,但正式优化仍须保留真实浮点格式化需求。

本文历史实测表以 B(字节) 为单位,工程估算及组件预算明确标 KiB(1,024 B)。单项、组合和功能缩减诊断不得混算。

已有问题复核与修复 PR(2026-10-03)

以下均以 main 8c0026e3a 复核。三个 PR 独立基于该 main,均为 ready for review;“PR 已修复”表示修复及本地验证已提交,不表示已经合入或远端 CI 已通过。

已有问题 当前核对结果 修复 / 后续
Wasm -size 调用 readelf 失败,浏览器误读 JS/HTML;缺最终编码和文件总量 确认仍存在;改为直接读取最终 Wasm,区分函数体、data、custom、结构字节和 memory limits #2719
ELF 依名称分类漏 unwind/init-array;局部标签截断函数、忽略 st_size;Go 方法名在括号处截断 确认仍存在;改按 ELF flags/type/st_size,区间只计一次,保留完整名称。历史 _dtoa_r 从误报 78 B 修正为 3,248 B #2719
请求的体积报告失败仍返回成功;固件转换和交付资源不在报告内 报告错误返回构建错误;格式转换后读取已有 artifact 清单,文件体积与 payload/内存指标分列 #2719
C-only + -deadcodedrop 的 nil metadata 失败 真正根因是把仅预加载、未构建的 runtime 包纳入链接闭包;并非已证明 C 依赖事实缺失。排除未构建 runtime,对真实链接包缺 metadata 明确报错 #2721
WASI Full LTO 的 LLVM promote 崩溃,ThinLTO 未按预期生效 编译/链接缺 -flto;真正执行 LTO 后,SjLj 还需要 IR 中的 EH/atomics 等 target features。两者一起修复,并传递链接优化等级 #2721
Value.Method/MethodByName → Interface → 普通调用 报 indirect call type mismatch WAMR 已复现;补足 typed bridge 识别,保留 Type 方法元数据查询的较小需求范围 #2720
Emscripten O3/Os/Oz 链接被降为 O2 以回避 Asyncify 控制导出丢失 复现 emcc 6.0.8 MetaDCE 对 EXPORT_ALL 链式绑定的识别缺口;用可识别的 preRun 绑定保活,恢复实际优化等级,保留既有 Module 导出和用户 preRun #2720
W32 的 Clang 隐式 wasm-opt 随 PATH 改变结果 最新 main 已修复:shouldDisableClangImplicitWasmOpt 对 Wasm Clang 链接传 --no-wasm-opt,本轮不重复提交 正式受控 size passes 仍属于后续优化能力
ESP32-C3 fmt.Printf 编译失败 尚未解决,已确认不是一个声明错误;见下方目标 runtime/ABI 缺口 保持本提案中的未完成项,不提交无法链接的临时修补

本地证据:#2719 的 WASI 三样例均构建并在 WAMR 执行,最终 JSON 与文件字节闭合;println 冷/热缓存的报告和 Wasm 哈希一致。#2721 的 println Full/Thin LTO、fmt Full LTO 冷/允许缓存构建及 Full-LTO threaded GC 均在 WAMR 通过;ESP32-C3 C-only DCE 两轮 ELF 构建通过,未声称硬件运行。#2720 覆盖独立反射方法值、J32/J64 的 callback、C ABI/EH、文件系统、双 worker,以及 raw js/wasm;浏览器双 worker 检查实际 distinct M 和正常退出。

这些是正确性回归证据,尚未重建完整的最新 main / TinyGo 匹配基线 B0,不替换下文历史消融数据。IR 归因摘要/缓存、完整保留原因、target 物理内存别名、动态内存以及后续体积优化仍按第 4、6、7 节推进;#2719 的 native RAM 是 Data + NOBITS section 口径,不能当作消除地址别名后的物理 RAM。

嵌入式 fmt 的实际阻塞。 使用 Go 1.27、Espressif Clang 22.1.4 与 newlib-esp32 patch7,执行 llgo build -target=esp32c3 ./benchmark/binary_size/fmtprintf,首先缺 IsPpc64/IsPpc64le/GOARCH 及 baremetal debug.Symbol。诊断中临时补声明、限定 Darwin-only sys/ioctl.h、启用现有 ARM Plan9ASM lowering 后,进一步得到 19 个未解析链接符号:

目标能力 缺失符号 / 需要解决的边界
libffi / Go closure ABI ffi_closure_alloc、ffi_closure_free、ffi_prep_closure_loc、ffi_prep_cif、ffi_call_go
原子操作 __atomic_fetch_or_4、__atomic_load_4、__atomic_store_4、__atomic_fetch_add_4、__atomic_fetch_add_8、__atomic_compare_exchange_8、__atomic_load_8、__atomic_compare_exchange_4、__atomic_exchange_4
GC / finalizer GC_register_finalizer 与 baremetal collector 的能力契约
os / syscall syscall、syscall.seek、runtime.fcntl、syscall.rawVforkSyscall;需要正确的 baremetal stdio/os/poll/syscall 选择边界

临时补丁已撤回,未用返回成功的 stub 绕过。后续应先确定实际活依赖,补齐需要的目标实现,再完成 fmt 编译及 emulator/硬件执行;不能把所有未解析符号都认定为每个 Hello 必须保留,也不能在未生成可运行产物前给该列填写实际大小或标完成。

1. 基准、目标和可比性

使用仓库 benchmark/binary_size 的三个样例:C printf、Go 内建 println、Go fmt.Printf,均打印 Hello, world。本轮选择 ESP32-C3 作为嵌入式样本;ARM/Cortex-M 的结论和数值需另行验证。

构建 / 度量(B) cprintf println fmtprintf
LLGo main,旧 WASI/Asyncify wasm 138,101 137,461 2,695,576
LLGo #2669,W32 threads wasm 147,452 147,091 2,335,854
TinyGo 0.42,WASI wasm:目标 25,346 19,922 84,833
LLGo main,浏览器 J32 wasm 142,921 142,157 3,184,232
LLGo main,浏览器配套 .mjs 73,548 73,548 117,976
TinyGo 0.42,浏览器 wasm:目标 27,638 21,281 159,180
TinyGo 0.42,共享 wasm_exec.js,每个应用冷启动计一次 17,089 17,089 17,089
LLGo main,ESP32-C3 Flash 22,562 42,800 编译失败,待修复
TinyGo 0.39,ESP32-C3 Flash:暂定参考目标 4,198 4,128 9,152

固定的构建环境:

  • LLGo main commit:4dbedca26aaa3841139e6576d2555db24d84c819;W32 threads 使用 runtime/wasip1: add opt-in threaded GC for WAMR #2669 的 2f7deb00a9b41211df585cac3e9ead7f544c605c。这是两个不同快照/后端,不能把两行相减称为“仅切换 threads”的收益。
  • LLGo:Go 1.27、LLVM 22.1.8、Emscripten 6.0.8、Binaryen llgo-v132.3。TinyGo wasm:从 v0.42.0 源码构建,Go 1.27、LLVM 22.1.8,补齐该版本的 net / wasi-libc 子模块,使用同一 Binaryen。保留 TinyGo 默认 GC/scheduler/panic 配置,-no-debug、默认 -opt=z。
  • TinyGo ESP32-C3 暂用 Homebrew v0.39.0 + Go 1.25:本机 v0.42.0 源码包缺少生成后的 ESP device 定义,未完成该版本固件构建。TinyGo 使用 Picolibc,LLGo 使用 newlib-esp32;这一行并非同版本、同 libc 的严格对照,应先补齐再设强制门槛。
  • WASI/浏览器基准和下列主要优化组合均执行并核对输出;旧 WASI 用 Wasmtime 48.0.1,W32 threads 用 WAMR 2.4.5,浏览器产物本轮用 Node 24.10.0 runner 做 smoke test,尚非真实浏览器/多 worker 全量验证。
  • LLGo 浏览器是 Emscripten ABI,TinyGo 是 GoJS ABI。LLGo println 写 stderr,TinyGo 样例写 stdout。TinyGo 的 WASI 基准也不承担与 LLGo W32 threads 完全相同的线程/异常/反射能力。这些差异要追踪成本,不能靠静默降级消除。
  • Flash 统计可加载的代码、只读数据、初始化数据、初始化数组及存在的 .eh_frame,排除 DWARF、BSS、栈预留和地址空洞。ELF 文件大小不能当固件体积;缩小栈不能计入 Flash 收益。本轮未在 ESP32-C3 硬件执行。

C printf 的对照修正

TinyGo 不能直接导入 LLGo 的 github.com/goplus/lib/c,因此使用 C shim:static void hello(void) { printf("Hello, world\n"); }。LLVM 可将其折叠成 puts。又增加 volatile int newline = '\n'; printf("Hello, world%c", newline);,强制保留真实 C formatter:

TinyGo cprintf 对照(B) cprintf println fmtprintf
0.42 WASI,静态字符串 → 强制格式化 25,346 → 37,447 不适用 不适用
0.42 浏览器 wasm,同上 27,638 → 39,738 不适用 不适用
0.39 ESP32-C3 Flash,同上 4,198 → 5,682 不适用 不适用

因此目标应同时跟踪静态字符串和动态格式化两组,而不是把 puts 与完整 printf 的差额全部算成 LLGo 编译器缺陷。

2. 已实测的单项和组合消融

2.1 正式方向:W32 threads / WAMR

下表保留同一 #2669 快照的历史实验。原始 baseline 未固定 PATH 中的 wasm-opt;第 9 节证实 Clang 找到它时会隐式优化,因此该 baseline 不是所有环境的默认大小。除写明“组合”的行外,各行只相对该历史 baseline 改动该项。-Oz 使用 llgo-v132.3,不添加 --translate-to-exnref,保留此 WAMR 配置支持的 legacy EH 编码。

实验:最终 wasm 大小(B) cprintf println fmtprintf
原始 baseline 147,452 147,091 2,335,854
单项:-deadcodedrop 146,531 146,170 1,300,729
单项:额外 Binaryen -Oz 107,155 106,895 1,904,792
单项:Binaryen strip/re-encode,无优化 pass 116,479 116,177 2,102,485
组合:DCE + 最终 -Oz,保留 PCLN 106,724 106,463 1,165,321
上述组合相对 baseline 减少 40,728 40,628 1,170,533
诊断:单独 -pclntab=none 147,459 147,098 1,985,941
诊断组合:DCE + -pclntab=none 146,538 146,177 950,800
诊断组合:DCE + -pclntab=none + 最终 -Oz 106,724 106,463 815,385

上述 W32 输出均通过 WAMR smoke test。DCE 目前仍有其他目标的编译失败和语义验证工作,不能据三个样例就直接全局默认开启。

--strip-debug --strip-producers 那一行还包含 Binaryen 重编码对 padded LEB 的压缩:例如 cprintf Code 从 114,825 降至 102,546 B,Data 仍为 12,359 B,不能把全部收益归为删除调试信息。最终 -Oz 的收益与此高度重叠;不重复累计。LLD 的 compress-relocations 文档 也说明重定位填充与调试偏移的关系,debug 路径必须单独处理。

PCLN 删除只是成本上界实验。正式方向是 #2420 的存活记录筛选和压缩;删除后的 Hello 输出正确不代表 runtime.Caller/Stack/FuncForPC 仍完整。小样例未优化时出现的 +7 B 编码差异如实保留;再优化后两种 PCLN 配置的小样例相同。

2.2 浏览器 J32 / Emscripten

实验:最终 wasm 大小(B),各单项相对 main baseline cprintf println fmtprintf
baseline 142,921 142,157 3,184,232
单项:-deadcodedrop 142,010 141,255 1,647,294
单项:最终 wasm 额外 -Oz 139,850 139,102 2,950,069
诊断:-pclntab=none 142,921 142,157 2,828,656
诊断组合:DCE + -pclntab=none 142,010 141,255 1,292,041

当前 emscriptenLinkLevel 因 Emscripten MetaDCE 错删 Asyncify 控制导出,把最终链接的 -Oz/-Os/-O3 降为 -O2;LLVM 源码编译仍按请求等级。不能笼统说所有 wasm 路径已经完成同样的最终 -Oz。额外优化行保持 .mjs 不变、保留实际所需 wasm features,在 Node 中验证正确;集成时仍需浏览器/worker/EH/debug 回归。

进一步只修改 JS 导出配置:删除 LLGo 当前固定的 EXPORTED_RUNTIME_METHODS 列表,设置 EXPORTED_RUNTIME_METHODS=[]、EXPORT_ALL=0,使用完全相同输出文件名,结果如下。

实验:配套 JS 大小(B) cprintf println fmtprintf
baseline .mjs 73,548 73,548 117,976
最小 host API 导出实验 .mjs 22,720 22,720 62,509
JS 单项减少 50,828 50,828 55,467
对 wasm 的减少 0 0 0

这不是可以直接替换全局默认的开关:应用可能需要 FS、cwrap、UTF8 转换等 API。应支持应用声明导出清单,保留宿主/C 回调所需根。另需纠正一个常见归因:EXPORT_ALL 本身不会让死代码变活,它仅将已包含符号暴露到 Module;EXPORTED_RUNTIME_METHODS 才影响所请求 runtime API 的保留。上述联合实验不能把收益全归给 EXPORT_ALL。Emscripten settings

2.3 嵌入式:ESP32-C3

控制实验复制 esp32c3-basic 目标及其 board tags/配置,仅改变是否强制 --undefined=_printf_float;完整配置复现原始 baseline。

实验:Flash 大小(B) cprintf println fmtprintf
baseline 22,562 42,800 编译失败
单项:-deadcodedrop 编译器 panic 38,798 基线尚不可用
诊断:取消强制 _printf_float 6,042 28,946 基线尚不可用
取消强制 float 根的实测减少 16,520(73.2%) 13,854(32.4%) 待测
条件性上界:现有 .eh_frame 全部删除 100 3,544 待测

最后一行只是 section 大小,不是已验证的安全优化。需要 native unwinding/C++ EH 的程序必须保留;不得默认为了体积关闭 EH。浮点格式化也必须在实际使用 %f/%g 等格式时保持正确。

历史快照中有两个阻塞(本轮需在最新 main 复核):ESP32-C3 fmtprintf 的 IsPpc64/IsPpc64le、clitedebug.Symbol/GOARCH 未定义;cprintf + DCE 在 internal/meta.NewGlobalSummary 发生 nil pointer panic。后者观察到缺少 package metadata 的调用路径,根因和修复仍需完整验证。

2.4 旧 WASI 的诊断补充:已有通用优化不够

这部分只解释原有数据,不给已计划退出的单线程 WASI 新增产品承诺。

最终 wasm 大小(B),各单项相对旧 WASI baseline cprintf println fmtprintf
baseline 138,101 137,461 2,695,576
DCE 137,221 136,605 1,464,144
-pclntab=none 138,101 137,461 2,345,835
DCE + -pclntab=none 137,221 136,605 1,114,380
-lto=thin 138,100 137,460 2,695,768
再跑 Binaryen -Oz 138,074 137,434 2,694,792
再跑 -Oz --converge 138,074 137,434 2,694,790
--gufa -Oz 137,844 137,217 2,692,626
诊断:假设普通 imports 同步,仅保留 Asyncify 特殊边界 136,563 135,923 2,691,336

--merge-similar-functions -Oz 与该轮额外 -Oz 结果相同。-lto=full 的 println 在 LLVM 22 codegen 报 Do not know how to promote this operator!,涉及 runtime.printany。所以 Full LTO 修复是优化能力的前置工作,不是已有确定收益;ThinLTO 也不能在估算中自动记一次大幅缩减。

对捕获的同一次流水线,Code 在“pre-Asyncify -Oz 后 → Asyncify/EH 转换及最终优化后”为:

Code 阶段变化(B) cprintf println fmtprintf
旧 WASI baseline 76,881 → 126,143 76,672 → 125,514 1,212,977 → 2,036,126
净增长 49,262 48,842 823,149
fmtprintf DCE + none 对照 不适用 不适用 508,397 → 848,265

这仍是多个变换的净效应,不是“全部可删的 Asyncify 字节”。收紧普通 import 假设只节省约 1.5 / 1.5 / 4.2 KB,说明还应分析间接调用和 runtime 到可挂起点的传播。浏览器已有异步 import 清单,不能把该旧 WASI 实验的收益照搬过去。未来只在浏览器 Fiber/Asyncify 路线研究精确调用图;W32 threads 不运行 Asyncify。不能全局使用 IGNORE_INDIRECT 或手工排除可能经过 C/JS callback 挂起的路径。Asyncify 官方说明

3. 最终符号分布:钱花在哪里

3.1 W32 threads:DCE + 最终 -Oz,保留 PCLN

为符号分析单独加 -g 保留 name section;三个产物的 Code/Data 与无 name 的统计产物逐字节相同,并重新通过 WAMR smoke test。下面按优化后函数名归因,内联代码归入承载函数;不是源包的严格独占成本。name/debug section 不计入目标大小。

最终 section / 函数组大小(B) cprintf println fmtprintf
Code section payload 92,932 92,703 524,085
Data section payload 12,415 12,379 638,665
LLGo runtime 命名函数 62,284 59,722 99,122
C / ABI 命名函数 29,910 29,894 29,526
main 命名函数(含内联) 348 2,697 9,916
reflect 0 0 88,749
fmt 0 0 76,165
unicode 0 0 51,198
Go runtime 0 0 41,018
syscall 0 0 17,984
internal/poll 0 0 16,128
internal/strconv 0 0 15,349
internal/fmtsort 0 0 14,516
os 0 0 13,927
sync 0 0 12,464

这只是主要组;函数体大小总和与 Code payload 之间还存在计数/编码开销。

典型最终符号(B) cprintf println fmtprintf
printf_core 8,468 8,468 8,530
LLGo tinygogc.Alloc 6,630 6,630 6,658
dlmalloc 6,634 6,634 6,634
LLGo Panic 8,030 8,029 不在此列列举
unicode.init — — 51,198
fmt.(*pp).printValue — — 16,286
reflect.makeMethodValue$1 — — 15,717
reflect.closureOf — — 12,988
Go runtime.init — — 11,999
runtime.SetFinalizer — — 6,738
reflect.wasmMakeFuncInvoke — — 5,066

由此提出的可验证问题:

  1. 一个不使用 Go 分配、goroutine、Go 导出/回调的 C-only 入口,是否真的需要完整 Go scheduler/context/GC/panic 类型分派?应以全程序证明和外部根声明决定,而非仅检查 import 名称。
  2. 内建 println("...") 为何保留 C formatter?当前 z_print.go 的整数等输出仍调用 c.Fprintf,通用 panic printer 又把多种类型格式化带入。应拆开低层输出和高层格式化依赖。
  3. fmt.Printf 无参数字符串为什么仍保留反射方法值、动态函数调用、Unicode 初始化?需要更细的类型流、方法/itab 可达性、常量参数传播,以及全局初始化依赖分析。
  4. DCE+Oz 的 fmtprintf 关闭 PCLN 后 Code 仍为 524,085 B,Data 从 638,665 降至 288,729 B。这 349,936 B 是该样例的删除对照,不是全部可无损裁剪量。当前 funcinfo 字符串表不能简单说成通过函数指针“保活全部代码”;需要独立处理记录的存活性。
  5. Alloc 与 dlmalloc 共存本身不证明实现重复。应分别分析 Go 对象/GC arena 和 C malloc 的实际调用链;保持 C malloc/free、线程同步及跨语言对象存活契约。

3.2 与前一轮 main / TinyGo 符号对照

归因摘要(B) cprintf println fmtprintf
LLGo 旧 WASI 最终 Code / Data 126,143 / 10,593 125,514 / 10,577 2,036,126 / 652,021
TinyGo 0.42 WASI 最终 Code / Data 22,970 / 380 17,918 / 324 75,623 / 5,500
LLGo J32 最终 Code / Data 132,522 / 8,789 131,784 / 8,765 2,499,693 / 674,177
TinyGo 0.42 浏览器目标 Code / Data 24,952 / 354 19,003 / 297 74,387 / 78,761
LLGo 旧 WASI 主要最终函数组 runtime 97,286;C/ABI 26,763 runtime 93,629;C/ABI 26,365 reflect 455,301;time 232,212;fmt 221,195;poll 188,232
TinyGo WASI 代表符号 强制格式化对照的 printf_core 7,462 hashmapSet 2,828;alloc 2,247;_start 1,357 (time.Time).String 11,738;reflectlite.RawType.String 6,983
LLGo ESP32-C3 代表符号 _dtoa_r 3,248;__divdf3 1,022;__adddf3 954 同样存在浮点格式化及软件浮点依赖 尚无法编译分析
TinyGo ESP32-C3 代表符号 静态字符串路径 puts 80 轻量 runtime 打印 需以 0.42 匹配版本补测

main 的名字保留重编译匹配最终 Code/Data 长度和输出;fmt 的 Code 编码并非逐字节相同,不能声称与原文件完全一致。更早的 linker map 是 Binaryen/Asyncify 之前的符号大小,不与最终产物混合求和。

4. 从 TinyGo 及其他工具借鉴什么

TinyGo 的重点不是单个 LLVM 参数,而是让优化器看到更精确的全程序信息:编译期执行可处理的初始化、接口 lowering 与优化互相迭代、运行时调用特化,以及体积较小的目标 runtime/libc。源码入口包括 builder/build.go、transform/optimizer.go、interface-lowering.go、interp;整体见 pipeline 和 优化指南。

LLGo 已有 cl/static_init.go,应扩展现有静态初始化及全局依赖分析,不能重复实现一套“从零静态初始化”。尤其要区分可以常量化的数据、可删除的无用初始化,以及必须保留的 I/O、副作用和 G/thread-local 初始化。Code 转成同样大小的 Data 不算总体积收益。

已有 #2398、#2337、#2291、#1752 的 DCE/ThinLTO/接口工作需要统一复用;元数据复用 #2420,逃逸/heap-to-stack 参考 #2244、#2173。不要叠加多个相互独立、root 规则不一致的优化管线。

其他有价值的方向:

  • Picolibc printf 配置:按需要选择实现、让简单输出不依赖通用 formatter。更换 libc 前需匹配格式化能力并验证 FILE、errno、reentrancy、malloc 和 ESP port;不能把关闭浮点带来的收益归给 libc 实现更优。
  • LLD WebAssembly:函数/数据 section GC 和重定位压缩。--gc-sections 默认已启用;难点是 LLGo 保留了哪些根,而非再添加同名开关。
  • Binaryen:把已有 size passes 应用到遗漏的目标,并保留 EH/features/debug 信息。重复 -Oz、GUFA、函数合并在已充分优化的旧 WASI 上收益很小,优先级低于可达性。
  • Emscripten:联合 JS/wasm 可达性和显式导出清单。MINIMAL_RUNTIME、无文件系统等只适用于明确满足限制的 profile,不能直接替代要求 C/POSIX 兼容的通用浏览器构建。

TinyGo 的大小也包含能力取舍:v0.42.0 的 runtime/stack.go 中 Caller/Stack/FuncForPC 等仍是 stub。因此以其大小为目标,并不意味着允许把 LLGo 相应 API 改成 stub。

4.1 统一的需求分析,而不是多套互相独立的 DCE

扩展现有 internal/meta、internal/deadcode / internal/dcepass 和 internal/build。包缓存保存可复用的保守事实,最终应用链接产生专属优化计划;不要在包缓存中写入某一个应用的已裁剪方法集。

事实至少包括下列对象及其保留原因:

对象 需要表达的事实 保守规则
函数 / 闭包 / thunk 直接调用、函数值可能目标、interface invoke、C/JS callback、导出 未知间接目标保留其可能目标集合,不能按“目前未执行”删除
类型 / 方法 接口断言/分派、相等/hash/GC、字段检查、方法枚举、按名查找、动态 Call/MakeFunc/类型构造 动态类型/方法名及外部输入扩展需求,不因为优化样例简单而截断
全局 / init 读取、写入、逃逸、依赖顺序、I/O/注册/TLS 等副作用 没有普通数据读取不意味着 init 无副作用
runtime 子系统 分配/回收、finalizer/weak、线程附着、G/channel/timer、panic、符号化 只删除不可达的实现,不将仍可执行的 API 变成 stub
宿主 / C ABI 导出、取址符号、native ctor/dtor、JS 属性访问、worker 入口、EH、阻塞/挂起 预编译 C 库或外部 JS 缺少事实时保守处理;不能假定同步或无回调

根来源有唯一的定义:用户入口及有副作用的 init、显式导出、C archive/shared 的公开入口、地址逃逸和注册的回调、目标要求的启动/TLS/GC/EH/worker 边界,以及用户宿主 API 清单。分析结果必须同时支撑 Go DCE、LLVM internalization、链接器导出、JS MetaDCE 和 Asyncify。缺失 C-package metadata 要作为明确的“保守未知”或构建诊断处理,禁止 nil 解引用,也禁止把缺失等同于“无依赖”。

元数据中的函数指针不是可以无视的真实引用。要区分执行需求和描述信息,并先在所属包 IR 中按经过验证的计划改写死方法入口,才能让 LLVM/链接器看到可删边;仅在另一个分析图中忽略引用无效且可能不安全。保留 go:linkname、llvm.used、COMDAT、C ctor 和外部可见性规则。

优化日志给出“入口 → interface/method/init/runtime → 被保留项”的路径和未知来源。每轮优化后,最大函数/数据块的保留原因要能解释,避免只凭包名猜测成本。

4.2 与 LLVM / ThinLTO 交替收敛的编译管线

借鉴 TinyGo 在 LLVM 简化前后做专用 lowering 的顺序,复用 LLGo 已有全局方法分析及 #2398 所属包写回边界:

  1. 包编译生成规范 IR、语义事实和可裁剪 metadata;先做局部常量传播和已有静态初始化。
  2. 应用链接收集外部根,计算保守类型流、方法需求、init effects 和 runtime 能力闭包。
  3. 在 owner package 写回死方法入口及无用初始化依赖,进行接口去虚化、可证明的调用特化;导出/地址逃逸仍保留。
  4. LLVM/ThinLTO 做内联、IPSCCP、globalopt、DCE 等,取得新增直接调用及已简化的能力需求,再生成新的保守事实。
  5. 重新计算需求和裁剪计划;达到稳定的需求/计划哈希后结束。迭代次数和 IR 成本有上限;到上限保留最后一个经验证的保守计划,不采用未验证的更小集合。
  6. 按保守计划生成可关联的 metadata carrier 并链接;Wasm 执行规定的 Asyncify/EH/Binaryen/JS 变换,确定最终代码存活集合;之后再完成元数据裁剪/编码、身份和 DWARF 验证,量最终体积。元数据定稿后不再运行未更新映射的代码变换。

不能将旧的已裁剪摘要当成完整输入,否则会漏掉反射或特化 helper 新引入的依赖。新的 helper、导出或动态类型也必须进入根/需求分析。库、未知插件和开放世界模式保持保守。

#2291/#2337/#2398/#1752 仍是未合入的相关工作:选择一个共同 plan/owner-writeback 接口,复用其有价值的部分,不能串联四套各自决定 roots 的优化器。ThinLTO 和 Full LTO 是实现途径,不把“开 LTO”单独记为必然收益;内联/函数克隆还可能增大 Code。按 size 模式限制克隆预算,比较 Oz、Os 与 ThinLTO 的实际总字节后决定策略。

缓存区分规范包缓存和应用专属物化结果;后者的 key 覆盖 roots、capabilities、优化计划/格式版本、C/host 清单、target/features、toolchain、metadata/debug 策略和前置 IR 哈希。冷/热缓存必须产生等价输出,不以永久禁用缓存解决计划失效。

来源:TinyGo optimizer、interface lowering。TinyGo 源码显示专用 interface lowering 前后都运行 LLVM 简化;LLGo 的多轮计划属于本提案设计,并非声称 TinyGo 使用完全相同的实现。

4.3 按类型需求分离接口、GC 与反射元数据

目标不是“禁用高级 reflect”,而是让基础值检查不总是绑定到方法闭包、动态调用、类型构造的全套实现。

  • 核心类型身份、布局、对齐、GC 需求、相等/hash 和接口语义保持正确;不同命名类型不能因相同布局而合并。
  • 以具体类型 + 被观察的操作表示需求。基础 TypeOf/字段/tag、Implements/AssignableTo、方法枚举/按名查询、Value.Call/MakeFunc、动态构造等分别产生依赖;Stringer/error/Formatter 的普通接口调用也是执行需求。
  • 首版保持现有核心 ABI,先清除确实无调用需求的 IFn/TFn 引用;需要 Type.NumMethod/Method 枚举时仍保留其数量、顺序、名称、签名及能被调用的入口。无法证明时保持完整方法集。
  • 之后把未观察的扩展表单独生成/归并,让字段/方法/名字/函数签名数据可分别存活。若现有 ABI 不允许某种可选布局,必须一起版本化 compiler/runtime/缓存,不能仅改计数或删数组。
  • MakeFunc、StructOf 等运行时创建的类型继续使用同一身份和查找契约;不能仅针对静态 Type ID 设计,亦不能改变 Interface() 得到函数值后的普通调用行为。

TinyGo 可以删掉某些 method-set 数据,是因为它的动态反射契约也更小。LLGo 不能在仍可能 MethodByName/Call 的类型上直接复制该删除规则。第 8 节的 32,635 B 功能缩减实验说明公共 API stub 不是主要解法;123,416 B 类型描述符也不是整块可删的反射成本。

4.4 初始化和静态数据必须一起优化

扩展已有 cl/static_init.go,先实现有界、可终止的纯表达式求值和全局依赖切片,再扩大可解释的 init 子集。复用 TinyGo 的思路,不在第一阶段重做一个无边界的解释器。

对每个 init 步骤记录被写对象、别名/逃逸、读依赖和副作用。可删除无读者的纯初始化,可预计算只依赖常量的部分;遇到未知调用、I/O、随机数、时钟、注册、线程/TLS、C ctor 或不可证明的别名时保留原步骤和顺序。部分求值不能提前执行本应在程序启动时发生的副作用。

Unicode/strconv 查表采用“读需求 → 对应数据 → 初始化”的整体切片:先证明某张表/某项能力不可达,再删除其生成和静态载荷。基础 Unicode 正确性、动态 fmt 格式串和完整字符集不得靠样例 ASCII 来缩减。按页/范围裁剪需要索引和边界重写的证明,不可只删一段数组。

每次记录 Code 减少、Data 增加、总字节、启动耗时及初始 RAM;把 runtime map 预计算成同样大小的静态数据不是完整体积收益。保留 deterministic init、map/hash 语义和 GC 可见性;资源/迭代超限则退回原 init。

4.5 PCLN 和诊断数据以最终存活集合生成

复用 #2420 与现有 internal/pclnpost、internal/pclntab、funcinfo/PC-site 管线。先消除聚合常量中死函数记录和未使用字符串,再压缩存活信息;不是先搬成 sidecar。

Native 按链接后的函数入口/PC-site ownership 裁剪;处理 aliases、wrappers、闭包、ICF、LTO inline copy,统一重映射 function index、字符串/offset、hash 和 PC-line 表,并核对所有消费者引用。物理文件中若只是把死记录写零仍不算体积消除;需要相应 payload 重建/压缩并核对实际文件长度。

Wasm 经 Binaryen 会重排/合并函数,因此编译器逻辑 function/site ID 与物理 function index 分开。元数据不能通过函数指针把全部代码保活;最终变换必须产出或保留可验证的 live-ID/ownership 映射。Wasm 的逻辑 ID 保持稳定,裁剪后的表通过 ID→紧凑行映射查找,不把重新编号的物理行号当成原 ID。能回溯的所有 frames,包括 inline 与 callback/EH 边界,都要保留对应记录;没有可靠映射时采用保守路径并报告原因。

首版 Wasm 采用独立的 metadata data segment/载荷,保留活程序的线性内存地址和代码引用,只缩减其物理初始化内容;由此留下的虚拟预留仍记入 RAM,不宣称同步减少。若后续要移动其它数据或重编号消费者,必须有完整 relocation/引用重写能力并重新验证。最终定稿与现有 build-ID/sidecar 机制共用规范身份输入;身份字段自身不循环参与哈希,文件 hash 在最终写入后计算。

紧凑数据使用共享字符串、delta/varint PC/line 和可索引的表,保留 bounded lookup 和无分配故障路径。外部编码采用明确版本及固定宽度/溢出规则,不能序列化 native uintptr 布局。编译期生成和启动时查询分别测成本,不以每次扫描/解压整个表换字节。

Caller/Callers/Stack/FuncForPC、panic、profiling 和 DWARF 是不同的消费者。PCLN、DWARF 的策略保持独立;最近合入的 native/embedded/Wasm debug 验收必须成为回归。DWARF 不计入 MCU Flash 时,其运行代码和 .eh_frame 仍按真实需求计算。

#2480 的 external PCLN / code splitting 继续负责部署和首次载入优化。本提案的“对齐 TinyGo”默认核对完整运行资源:sidecar、deferred module 和共享 loader 都不能消失在分母之外;使用降级符号化的模式单列,不能冒充默认语义不变。

4.6 分离 runtime 子系统,削减小程序固定成本

现有小样例 Code 中 LLGo runtime 占约 60 KiB;仅优化 fmt 不能使 println 接近 20 KiB。把无条件初始化/聚合函数拆成可独立可达的组件:

组件 何时必须保留 可以裁剪的条件
最小目标启动、stdio、退出 每个正常可执行程序及目标 ABI 只裁剪不需要的辅助分派
Go 分配/GC、arena 管理 活分配、跨回调/线程的 Go 对象、动态反射及相关 init 闭包内完全没有 Go heap/保活需求;不能改成 leaking
finalizer/weak/统计 对应 API 或 os.File 等实际注册 没有执行需求及隐含使用;不可只检查用户直接调用
G、channel、timer、worker/线程创建 go/channel/select/sleep、库及 callback 需求 整个闭包无使用且启动无可观察副作用
外部线程附着/TLS/根注册 C/JS→Go、外部线程、callback/root contract 无外部入口与地址逃逸,宿主不要求该桥接
panic 与符号化 活故障路径、用户 panic、反射错误及诊断 API 依据可能 panic 值/站点裁剪细分路径,保留剩余诊断
C malloc、FILE、同步及 EH 活 C ABI 和 C/C++ 库需要 按 C archive/host 根裁剪;Go GC arena 与 C malloc 不视作重复

“C-only 入口”表示已证明的依赖闭包,而不是见到 import C 就走快捷路径。main/init、参数转换、C ctor、用户导出、注册回调和隐式 Go 分配均须分析。无法证明时回到通用启动,不改任何 API 能力。println 同样应用能力切片,但仍执行 Go 的输出和必要故障语义。

本项改变依赖组织和链接选择,不重写调度器:W32 继续当前 pthread backend;浏览器继续当前 bounded worker/scheduler;非 wasm 继续 pthread/thread C 兼容路线。暂停、安全点、STW 根和 EH 各自作为不可漏掉的边界。

4.7 输出和 fmt 特化从语义证明开始

内建 print/println 与 panic printer 使用独立轻量输出和整数/指针 helper,避免字符串或整数输出通过 Fprintf 保留整个 C formatter。保持输出目的地、格式、顺序及支持的 panic 类型;浮点/复数 helper 仅在需求存在时保留。不要把 stderr 改成 stdout 来匹配 TinyGo 样例。

fmt 优先做通用的调用点特化:常量格式串、已知实参类型、已证明的 writer/receiver,以及 escape/接口分派。第一步可以处理“无格式指令、无参数”的安全子集;后续支持具体 verb/type 的组合。保留实时读取 os.Stdout、n/error、参数求值顺序、短写、Formatter/Stringer/error、%T/%v/宽度精度、Unicode 和非法格式的诊断。不能用 puts 替换 fmt.Printf,也不能识别 Hello 内容。

对 fmt.Fprintf 的未知 writer,保留 Write 次数和字节语义;不能随意改用可被自定义实现覆盖的 StringWriter.WriteString。无法证明等价的调用仍走原 fmt。限制克隆数及专用代码总预算,特化使产物增大时不采用。

继续分析 fmt → os/io/poll/time/sync 的闭包:stdio、普通文件、异步 poll、timer 等按目标和实际 fd 行为拆开,不能假定所有 stdout 都是永不阻塞的普通文件。目标适配层实现最小需要的行为,公开 os/io/fmt 语义保持一致。单独去虚化 fmt 但仍由 init 保留全部 poll/time 并不足以达标。

4.8 浏览器:Emscripten 生态、胶水与挂起图一起裁剪

保留 Emscripten C/C++ ABI 和已有 Fiber/Asyncify、EH、JS callback、FS 及多 worker 契约。构建阶段基于统一计划生成 wasm exports/imports、JS runtime API、worker 入口和 suspension roots,再交给联合 JS/wasm MetaDCE。修复目前 emcc -O3/Os/Oz 丢 Asyncify 控制导出的根问题后,才能恢复完整 size 管线。

宿主 JS 不是自动可见的 Go 调用图。封闭应用声明或由工具生成 host API 清单;未声明/未知外部使用的兼容构建保持保守。FS/cwrap/UTF8/table access、用户 C 导出、回调和 worker 入口按真实使用保留。显式最小清单不能成为悄悄删除现有公开 Module API 的全局默认。

Asyncify 的 must-suspend 集合由异步 imports、scheduler transitions、可阻塞 C 路径及经回调间接挂起的调用组成;同步/异步声明是验证过的边界契约。未知 C、未知函数指针/JS 回调默认保守。禁止全局 IGNORE_INDIRECT 或为降体积标记实际可能挂起的调用为同步。联合类型流缩小间接目标后再缩小插桩闭包。

JS 成本继续从最小导出实验的约 22 KiB 向 TinyGo loader 的 17,089 B 推进:裁剪不用的 host/FS/debug helper、按使用生成 worker loader、字符串/表共享和 minification,测单应用冷启动总资源。共享胶水/缓存命中另列;不复制几个小 loader 却只计其中一个。MINIMAL_RUNTIME/关闭 FS 只用于明确满足其 ABI/能力限制的构建,不能替代通用 C 生态支持。

J32/J64 各自核对 features、内存宽度、DWARF 和 EH。TinyGo 当前历史数据是 wasm32;J64 没有同能力 TinyGo 基线时报告自身消融和差距来源,不伪造一个相同门槛。

4.9 嵌入式:格式化能力、ABI 与物理内存布局

先在最新 main 重测 embedded fmt 和 C-package DCE 的历史编译失败,补齐 ESP32-C3 同版本/同 board/libc-capability 对照,并加入 Cortex-M。fmt 未编译运行成功时,该列不能用 Wasm 的比例估收益或标完成。

把 ESP32-C3 的强制 _printf_float 根改成能表达真实需求的链接决策。常量格式串与静态参数能证明不需浮点时裁剪;动态格式串、opaque C archive 或未知调用保留完整已支持能力。不能仅因为这个调用用了 %c,就断言同一个动态 formatter 永远不需 %f/%g。scanf 也按同一原则处理。

C printf(literal) → puts 必须符合输出、格式解析和返回值使用的等价条件;仅出现常量字符串不能无条件改写有观察返回值的调用。折叠后还要通过输出依赖解耦和 archive section 组织让 formatter/soft-float 真正可删。

Picolibc/newlib 比较使用相同格式范围、float/locale/errno/FILE/reentrancy、malloc/free、thread safety 和 C++ EH 能力。集成目标 port 后测量,不能把 TinyGo 所选的较小能力集误算成 libc 实现效率收益。不支持的组合明确失败,不生成悄悄缺 printf 浮点的固件。

Flash 按实际 LMA/加载区域计算,区分执行于 RAM 的代码、ROM 代码、复制的初始化数据、init-array 和 unwind。镜像文件及传输封装另计;BSS/栈/heap 预留和峰值 RAM 分开。FPU、RISC-V 压缩指令/relaxation、ARM Thumb/outline helper 只能在 MCU 支持时使用并消融;不通过换板或改 ISA 偷换基线。

4.10 统一体积报告及未来后端约束

计量与归因分开:最终产物决定实际字节,LLVM module 及包缓存摘要解释这些字节属于谁、为何被保留。 llgo build -size 默认应以链接和全部后处理之后的交付产物为准;不能改成累加包 IR 的大小。现有 module/package/full 中的 module 指 Go module,不是 LLVM module。

数据阶段 可提供的事实 计量边界
包 LLVM module / IR 摘要 符号与 Go module/package/source 归属、引用/调用边、类型/init/runtime 需求、IR 指令数、DataLayout 下的静态对象布局 IR 指令数不是机器码字节;global 布局也不是最终文件占用。仅作阶段诊断与归因输入
发射后的 object / 包 archive 非 LTO 对象的真实 section/symbol 字节、链接前输入分布 尚未完成全程序优化及 section GC/ICF/relaxation;Full/Thin LTO archive 成员可能是 bitcode,其文件大小更不是执行代码大小
最终链接及后处理产物 实际文件字节、载入布局、Wasm function/data 编码、完整交付资源、固件 Flash/静态 RAM 作为对齐 TinyGo 和消融的权威基准;归因缺失不影响总量计数,但须显示 unknown

即使取得 post-LTO module,也只能解释一个中间阶段:后续机器码生成、布局,以及 Wasm 的 Asyncify/Binaryen 重写仍会改变结果。ThinLTO 会跨 module 导入和优化,原始包边界不等于最终代码边界;见 LLVM ThinLTO。不为 -size 额外执行一次不同设置的 codegen,再把得到的大小当成原构建的最终结果。

缓存和 LLVM 生命周期。 当前缓存命中复用包 .a,仍重建提供链接元数据的前端 module,但跳过后端编译;因此冷/热缓存下现场 module 不处于相同优化阶段。已有 .meta 存储语义事实而非最终字节,不能直接当体积账本。源码见 cache-hit frontend、skip backend、LTO object emission。

按包在固定阶段、LLVM module 释放前提取轻量归因摘要,与 archive 一起发布/校验;复用现有语义事实,体积诊断增加独立的版本化字段或 sidecar,不把应用专属裁剪结果写回规范包缓存。最小摘要保存逻辑符号身份、linkage/所属包、来源和必要映射;较重的 IR 统计按需记录。摘要声明 schema、采集阶段、archive/input 哈希及 target/DataLayout、优化/LTO、toolchain 配置;命中缓存读取该摘要,不能拿重建的前端 module 冒充优化后结果。最终应用报告另绑定实际产物哈希、链接输入和后处理清单。

保留已合入的按包 snapshot/dispose 和链接前 backend 释放;不能为了报告一直持有所有 LLVM module,或要求每次保存完整 IR。旧缓存没有摘要时,优先从已有 object/bitcode/符号信息恢复能够证明的事实,缺项标记 partial/unknown;显式要求完整归因时只重建缺失的必要包。普通 -size 不禁用全部缓存;在摘要完整、构建输入相同的条件下,冷/热缓存必须得到相同的阶段度量与归因。参见 backend release。

从最终物理区间归因。 通过最终符号/linker map、经过验证的 postlink 映射及上述摘要关联到包;链接符号身份还需区分 local/COMDAT 所属输入,不能只按显示名称连接。Wasm 变换前后不能假定 function index 不变。LTO 内联、克隆、合并的数据/函数不再与包 IR 一一对应:默认把每个最终物理区间只计一次;可确认的内容归给最终承载符号,多个 owner 共享且不可拆分的内容列 shared,无法确认则列 unknown。inline 来源、保留原因是补充维度,不再把同一字节分别计给 caller 和 callee。符号消失只表明该符号未独立保留,不能直接宣称其 IR 全部变成了节省字节。

给出 stage/tool/flags/target/features 和 artifacts 清单。Wasm 直接解码最终 sections/functions/data,统计 Code/Data/custom/结构编码,并列出 JS、worker、PCLN 明细。内嵌 PCLN 是所在载荷的细分项,不重复累加;外置 PCLN、JS 与 worker 等资源按文件身份去重。浏览器构建应定位实际 sibling .wasm,不能把 .html/.js/.mjs 当对象文件读取。ELF 用 flags/type/st_size、LMA 与 memory region,排除局部标签和地址别名的重复;无可靠 symbol size 时明确区间估算及 padding,不能一律把直到下一符号的空隙算作函数代码。module/package/full 归因连同 shared/unknown/padding 按各自计量口径对账;文件字节、Flash、静态 RAM 分列,不把这些不同指标相加。固件格式转换完成后再记录各交付文件,区分镜像封装/地址间隙与有效 Flash 载荷,ELF、BIN/HEX/UF2 等替代格式不重复计入运行资源。

当前 -size 已在链接/PCLN/debug/strip 后运行,但还早于 firmware 格式转换;它按相邻符号地址差计量,且可能遗漏未知 section,因此“读取了产物”尚不等于“完整准确”。以上是对现有实现的修正设计,源码见 finalization order、size parser。

优化开关、链接后处理、工具调用和发生/未发生的 passes 都记录;W32/WASI 的显式 size 流程不依赖 PATH 中碰巧存在的 wasm-opt,不重复领取 Clang 隐式 Oz 收益。J32/J64 顺序按已验证的 Asyncify/EH/DWARF contract;重编码/relocation compression 的收益与 debug 偏移变化分别解释。产物和 manifest 原子发布。

报告失败提供机器可判定状态,分别表达总量计量和来源归因的完整度;CI 请求完整度量时失败不能被一条 Warning 隐藏。没有源码归因信息时保留准确的 section/总量和 unknown,不编造 package 分摊。strip 前从同一次构建保存来源信息,并校验它仍匹配最终 Code/Data;诊断版归因须核对非 custom payload。若另开 DWARF/debug 编译改变代码,将其标为辅助诊断,不能与 release 的函数大小相加。IR 指标、object 字节和 final 字节分列,只对计量口径可比的指标作差;真正优化收益仍以相同条件下的最终产物消融确认。

共享能力图与元数据逻辑身份不依赖 Wasm32、某个 EH 编码或某个 GC 的线性内存指针布局。未来 Wasm GC / JSPI / 新 EH 通过后端 lowering 和边界能力接入;本提案不预支未来 GC 或 stack switching 的收益。多线程下的可达性与 GC 存活根是不同概念,裁剪计划不能删除 TLS、foreign-thread、suspended G 或 worker 中仍需扫描的根。

本轮源码核对入口:GlobalSummary、ABI type generation、static init、size report、Wasm postlink、Emscripten linking。本节的统一需求、分层布局和反馈流程是待实现设计。

5. 待实现优化的逐项消融估算(历史参考)

以下是用于排优先级的工程估算,不是实测值或统计置信区间。实际可能为零,甚至因特化/初始化展开而增大。 根据上面的存活符号/section 预算限定数量级,实施后必须用同一基线替换为实测。

5.1 W32 threads:统一参考点,保留功能

参考点 A = 已实测 DCE + 最终 -Oz,保留 PCLN:cprintf 106,724 B(104.2 KiB),println 106,463 B(104.0 KiB),fmtprintf 1,165,321 B(1,138.0 KiB)。

表中每格为“预计单项减少量”;实施该项后的大小 = A − 该格。每行分别施加于 A,除明确说明前置条件的行外,不是假设前几行已经完成。多个项目作用于同一函数/数据时必须消融去重。

待实现项:相对 A 预计减少(KiB) cprintf println fmtprintf 依据、依赖和确定性
全程序类型流 / itab / 方法可达性,接口去虚化后再 DCE 5–15 5–15 90–180 fmt 中 reflect 86.7 KiB、fmt 74.4 KiB 及外围分派;主要估 Code,不包含下面的 PCLN 删除。中低确定性;保留动态反射和外部入口
常量格式串和实参类型特化,通用路径按真实需要保留 0–1 0–1 50–140 面向任意符合条件的调用,不识别 Hello 文本;必须保留 Formatter/Stringer、错误及输出语义。低确定性,与上一项大量重叠
扩展静态初始化、裁剪无用全局的初始化及数据 0–2 0–2 40–100 unicode.init 50 KiB、其他 init 和其依赖数据;扣除静态化新增 Data 后计算。中低确定性,不删可观察副作用
PCLN/funcinfo 按最终活函数筛选、字符串去重/紧凑编码 0–1 0–1 200–320 删除对照上界 341.7 KiB,正式实现必须保留存活记录和诊断。中等确定性;实现 #2420 而非 pclntab=none
Go 内建/故障输出与 C fprintf 解耦,按实际 panic 类型特化 3–10 12–25 10–25 printf_core 约 8.3 KiB,加多类型打印/分派;cprintf 自身仍用 printf 时不能删 formatter。中低确定性,与类型流优化重叠
证明 C-only 入口无需 Go runtime 启动,按需保留桥接 55–80 0 0 小程序 runtime 约 60 KiB,另有调度/分配边界;仅适用于证明无 Go 分配/G/回调/导出的入口。低确定性,含前述 runtime 裁剪收益
GC finalizer/weak/统计等独立子功能按可达性链接 2–8 2–8 0–8 不改为 gc=leaking;fmt 的 os.File finalizer 可能实际需要。低确定性,必须查看根链而非按符号名删除
caller/PC 站点编码和 helper 开销压缩 2–6 2–6 8–25 保留 panic/Caller/debug 功能,估 Code 及独立站点元数据;与 PCLN 数据部分去重。低确定性
类型描述、方法表和非 PCLN 字符串的存活性/布局优化 1–3 1–3 40–80(第 8 节修订) fmt 去 PCLN 后仍有约 282 KiB Data;不能认为其全是可删元数据。低确定性,与类型流/init 裁剪重叠
逃逸分析、heap-to-stack、分配调用特化 0–1 0–1 1–10 主要可能改善 RAM/速度,代码体积收益保守计;不能据此承诺移除所有 GC。低确定性
C printf(literal) → puts,并使无用 formatter 真正可删 单独 0–1;输出解耦后约 8–12 0 0 C 格式化等价需可证明;仍被 runtime fprintf 保活时只改调用几乎无效,与输出解耦/C-only 路径重叠
修复 Full LTO、统一 ThinLTO 反馈 暂记 0 暂记 0 暂记 0 属上述跨函数优化的前置能力;已有 ThinLTO 对照无显著收益,不能再单列一次“LTO 大收益”

例如,仅 PCLN 保真裁剪的规划区间,对 fmtprintf 意味着 约 818–938 KiB 的单项结果;它不能单独达到 82.8 KiB 的 TinyGo 目标。即使使用删掉 PCLN 的诊断组合,仍是 815,385 B,说明必须继续消除不必要的代码和非 PCLN 数据。

5.2 浏览器专属项目

浏览器仍保留 Emscripten、Fiber/Asyncify、EH 和 C/JS callback。这里的 A 不是上一节 W32,不能混用绝对值。

项目:预计或实测减少 cprintf println fmtprintf 参考点及约束
正确接入最终 -Oz:已测 wasm 减少 B 3,071 3,055 234,163 相对 J32 main 原始 baseline;与下行重复部分不累加
修复 MetaDCE 根后恢复完整 emcc size 链:额外估算 KiB 0–3 0–3 0–40 在已有最终 -Oz 之后,尚未验证;不能保证有额外收益
应用 host API 导出清单:已测 JS 减少 B 50,828 50,828 55,467 最小导出样例,相对 baseline;实际应用随 FS/cwrap 等需求变化
精确间接调用/挂起传播:额外估算 wasm KiB 5–15 5–15 40–120 以 J32 DCE 产物为参考,低确定性;须有全程序/C callback 证明,不能忽略可能挂起边

通用可达性、初始化、PCLN 优化也应应用到 J32/J64,但须重新实测,不能将 W32 的 KiB 机械搬来。Future WasmGC 可以替换 GC 后端实现,当前元数据压缩和根分析应通过后端接口表达,不固化线性内存指针假设;本提案不提前把 WasmGC 的潜在收益计入预算。

5.3 嵌入式待实现项目

参考点 E = 原始 ESP32-C3 Flash 22,562 / 42,800 B。由于 fmtprintf 尚无法构建,该列没有可信的绝对消融估算;先修编译,禁止按 wasm 比例外推。

项目:预计减少(KiB),另有标注除外 cprintf println fmtprintf 依据与限制
按实际使用链接 formatter;常量 C 调用折叠,并取消无条件 float 根 12–18 需输出解耦;联合约 12–20 待基线 16,520 / 13,854 B 诊断差额提供方向;保持实际浮点格式化能力,属于有前置条件的联合估算
内建/panic 输出轻量化 0–1 6–12 待基线 估算不重复包含上行的浮点库部分;仍可能共享 helper,组合须复测
Picolibc 与 newlib 等功能对照、archive 组织优化 0–5 0–5 待基线 在 formatter/根问题解决后估增量;低确定性,可能无收益,需要相同 float/locale/ABI 条件
修复并启用 Go 级 DCE 待修 panic 已测减少 4,002 B 待基线 与打印/全局根裁剪重叠,不能再次累计
不需要 native unwind 的显式 profile 最多 100 B 最多 3,544 B 待基线 .eh_frame 上界,尚未证明可全删;需要 C++ EH 的 profile 不适用

嵌入式是否接入硬件 FPU、指令集压缩扩展和 linker relaxation 必须按实际 MCU 能力核对;本轮不把未验证的架构选项算成通用收益。针对较大业务还应评估字符串/查表数据共享、只读数据布局及相同函数折叠,但小样例暂无独立可确认收益,先记 0,待 map 证明后追加。

6. 从差距到 TinyGo 的预算与收敛目标

6.1 目标按真实产物和能力矩阵冻结

完成条件是:核心样例在可证明封闭、语义保持的应用构建中,最终总字节达到对应 TinyGo 基线。不把用户源程序改成另一个程序,不删除仍可用到的反射/GC/panic/线程能力,不用受限 runtime profile 的数字替代默认 TinyGo 对照。宿主外部入口未知的兼容构建保留保守行为并单列成本;不能靠假设这些入口不存在来取得主表成绩。

每个 target 保存同版本 TinyGo/Go/LLVM/Binaryen、源码、board/ISA、libc 能力、GC/scheduler/panic/debug 配置、输出及完整 artifact 清单。TinyGo 是参考实现,不要求 LLGo 采用其 ABI;双方不等价的能力另做增量样例。增加能力的成本须实测,不允许无条件给所有 Hello 样例加一笔“完整功能固定开销”而提高目标。

最终对齐目标:历史 TinyGo B;第一组工作重建后冻结 cprintf println fmtprintf
W32 对照的 TinyGo WASI,最终 wasm ≤25,346 ≤19,922 ≤84,833
J32 对照的 TinyGo 浏览器,最终 wasm ≤27,638 ≤21,281 ≤159,180
J32 单应用全部运行资源,含 JS,冷缓存 raw ≤44,727 ≤38,370 ≤176,269
ESP32-C3 Flash:暂定参考,同版本/板级能力匹配后设门槛 ≤4,198 ≤4,128 ≤9,152
动态 C formatter:WASI wasm,%c 强制格式化参考 ≤37,447 不适用 不适用
动态 C formatter:J32 wasm,%c 强制格式化参考 ≤39,738 不适用 不适用

静态 cprintf 与动态 formatter 同时跟踪;动态 float/宽度精度/scanf 等能力还须单独匹配和重测,%c 参考不能证明完整浮点能力相同。TinyGo 0.42 embedded 的干净构建、C archive 哈希与 board 对照未补齐前,ESP32-C3 只能记参考值。Cortex-M、fmt-rich、println-rich、files/JSON/template 等扩展矩阵同样先重测 TinyGo,再定相应目标,不从 Hello 或旧版本外推。

J64 保持支持并做完整消融;缺少 TinyGo memory64 等价基准时不设伪造的 TinyGo 比值。它的 pointer/table、worker、EH 和内存成本必须分别量出,不能通过停止支持 J64 完成优化。

6.2 对当前历史差距分别建立 Code 与 Data 预算

历史参考 A = DCE + 最终 Oz、PCLN 保真,不是最新 main 基线 B0:

W32 历史差距 cprintf println fmtprintf
A,最终 wasm B 106,724 106,463 1,165,321
A / TinyGo 4.21× 5.34× 13.74×
距目标尚需减少 B 81,378 86,541 1,080,488
必须减少占 A 比例 76.3% 81.3% 92.7%
Code payload:A → TinyGo B 92,932 → 22,970 92,703 → 17,918 524,085 → 75,623
Data payload:A → TinyGo B 12,415 → 380 12,379 → 324 638,665 → 5,500

Code payload 包含函数计数、长度和函数体,Data payload 包含数据段布局及初始化内容,不能与单独的函数体或“data segment 初始化内容”混算。section 头、容器和其他 sections 另行对账,所以 Code/Data 差额之和不必等于最终文件差额。

这明确要求小程序削减约 70–75 KB Code,而 fmt 同时消除约 448 KB Code 与 633 KB Data。现有 PCLN/类型/init/反射项目大量重叠,尚未证明覆盖全部差距。类型流、初始化、按需 runtime、fmt 调用特化和库依赖组织必须是完整设计的一部分,而不能只在 450–750 KiB 的粗估区间停下来。

为 W32 的最终实现给出初始组件预算;这是用于暴露冲突的设计配额,不是测量或“全部实施后”的预测:

W32 最终分项预算 KiB cprintf println fmtprintf
Code:Go/runtime/C 的全部活代码 ≤20 ≤15 ≤68
Data:包括活类型、常量、PCLN 等全部 runtime 元数据 ≤2 ≤2 ≤11
其他 sections、container 和编码 ≤2 ≤2 ≤3
上述总预算 B ≤24,576 ≤19,456 ≤83,968

实际编码若超过配额就重新分配组件预算,但最终 TinyGo 总目标不随意上涨。fmt 的额外保真 PCLN 需要靠更小的活代码闭包和紧凑元数据共同承担,不能从结果里排除。若同能力实测证明某 ABI 的必要开销无法进入预算,记录明确下界、代价和设计备选,在 issue 中评审目标调整;不以未经测量的“C 兼容/完整 reflect 天生更大”直接结束。

浏览器另分 wasm/JS/worker/PCLN,预算不得从 W32 机械搬运。embedded 则按 runtime、formatter、Go stdlib、metadata、board startup 分摊 Flash,同时约束 RAM。

6.3 阶段用比值推进,不能在中期宣布完成

第一组先建立最新 main 的 B0;历史 A 用来定位问题,不能成为所有新 PR 的减法基线。后续阶段适用于 TinyGo 有同工作负载、同能力的所有主要目标:

收敛阶段:W32 示例,B;其他目标使用同样比值 cprintf println fmtprintf
度量与正确性基线 记录 B0,不承诺固定收益 记录 B0 记录 B0
中期:≤2× TinyGo ≤50,692 ≤39,844 ≤169,666
接近:≤1.25× TinyGo,向上取整 ≤31,683 ≤24,903 ≤106,042
完成:≤1× TinyGo ≤25,346 ≤19,922 ≤84,833

这不是预测每个 PR 都能一次达到一行。阶段目标必须在正确性通过后用最终文件验证。大幅超预算时继续分析最大的保留闭包,而不是换一个更弱 profile 或把阶段值称为最终结果。

每轮输出 remaining gap = measured LLGo total − frozen TinyGo total;Code/Data/JS/Flash 分开,列最大存活项及 root path。超过 1 KiB 的主要项优先解释,未归因和 padding 单列。给下一轮明确的变换、预计影响范围、风险、独立/组合消融和反证条件;没有证据的收益记“未量化”,不能用乐观数填满差额。

6.4 每项收益都必须可关闭、可复核

第 5 节保留历史单项估算以供优先级参考。实施后分别报告:

  • B0 + 单项、以及当前累计结果关闭该项的 leave-one-out;依赖不能独立关闭时按同一组消融。
  • 最终产物、Code/Data/custom/JS/Flash/静态 RAM/峰值 RAM,完整运行资源及 TinyGo 比值。
  • 编译耗时、cold/warm cache、启动/首输出、代表性吞吐和 GC/worker 成本;不得用大量克隆、启动解压或更大 RAM 隐藏体积代价。
  • 缩减是否来自不可达实现消除、表示改善、部署移动或显式能力限制;后两类不能冒充语义保持的总字节消除。

最终验收是组合构建本身;不把单项减少量相加后推导达标。回归引入的新 helper/根/元数据会重新进入需求分析和预算。没有达到 TinyGo 的样例继续保持未完成状态,即使所有计划中的开关已经实现。

6.5 传输与部署优化单列

压缩、模块拆分及 sidecar 应继续沿 #2480 评估,但要单列“传输量/首次载入量”和“全部可执行资源”。把 PCLN 移到另一个文件不等于删除元数据,把 runtime 拆成共享模块也可能增加单应用总资源。

浏览器冷启动 wasm + JS:分别压缩后求和(B) cprintf println fmtprintf
LLGo baseline raw 216,469 215,705 3,302,208
LLGo gzip level 9 72,563 72,378 974,943
LLGo Brotli quality 11 60,369 60,144 696,049
TinyGo raw 44,727 38,370 176,269
TinyGo gzip level 9 14,810 12,862 66,632
TinyGo Brotli quality 11 12,771 11,108 51,090

TinyGo JS 可跨应用共享缓存,LLGo 当前生成胶水是每个产物的一部分。比较冷启动、复用缓存和共享 runtime 时都应明确计数边界。压缩不减少解码后的 wasm Code、Flash 或运行时 RAM。

7. 实施组织、关系与验收

计划组织为 4 组连贯贡献;每组内部可有多次提交和必要的审查调整,不为每个小开关创建 PR。下面是实施计划,本轮没有宣称这些改动已经实现。

贡献组 范围及交付物 依赖 完成证据
准确度量与受控构建管线 修复 final Wasm/ELF -size;最终字节与可缓存 IR 归因摘要关联;版本化 JSON/资源清单;统一 pinned Binaryen 和实际 pass 顺序;修复/复核 embedded fmt、C metadata、reflect 方法值和 Full LTO 历史失败;基准脚本与 PR CI 最新 main 三个样例及扩展探针可编译/运行;B0/TinyGo 清洁基线、section 对账、正确失败状态、冷/热缓存归因一致及旧缓存回退
全程序需求与存活元数据 统一 roots/type-flow/method/init/runtime facts;owner IR 写回与 LLVM/ThinLTO 反馈;静态 init/data 切片;核心 ABI 与按需扩展数据;#2420 的 PCLN 筛选/编码 度量组;复用相关 DCE/LTO PR 保留原因可查询;动态反射/外部根正确;独立/组合消融;数据及方法代码的真实物理缩减;cache-equivalent
runtime、输出与格式化依赖 组件化启动/分配/线程/诊断依赖;Go print/panic 与 C formatter 解耦;C-only 证明;fmt 安全特化和 os/io/poll/time 闭包;按需 formatter/Picolibc 比较 统一需求计划;度量组 小样例 Code 与 fmt 闭包显著减小;C/Go 格式化及 IO/error 等价;GC/EH/线程与 callback 无回退;有界代码克隆
目标集成与 TinyGo 收敛验收 Emscripten host/MetaDCE/Asyncify 图;J32/J64/worker;ESP32-C3/Cortex-M libc/ISA;完整资源与差距分析;默认启用和最后一段未闭合差距的修正 前三组;宿主部分可先基于统一计划开发 主要样例达到对应目标,扩展矩阵无回退;全部必需资源及动态成本计入;PR CI 可重复;未达标项目仍开放

不把 #2398/#2337/#2291/#1752 直接视为四个串行必需依赖,也不在本轮关闭任何旧 PR;先核对其保留的实现与统一接口,再由后续贡献复用或替代。#2420 的 native 与 Wasm 存活元数据工作在第二组复用;#2480 的 external PCLN/code splitting 则保持部署优化边界,不成为达到总大小目标的捷径。#2244/#2173 的 escape/heap-to-stack 以证明过的收益接入,不预记大幅体积减少。

基准与能力矩阵

三个主列始终是 cprintf、println、fmtprintf;每个目标附加如下工作负载,防止只优化 Hello:

用例组 重点
空 main / 有副作用 init / 纯 C / Go println 最小固定成本、ctor/init 顺序、无分配与活分配、runtime 切片
静态 C 输出 / 动态 C 格式化 / float / scanf 常量折叠与实际 formatter 能力、返回值/errno、opaque C 库
fmt literal / 动态格式 / typed values / 复合值 Formatter/Stringer/error、Unicode、%T/%v、宽度精度、错误格式与缺失实参
可替换 stdout / 自定义 writer / 短写 / 错误 相同 byte count/error、Write 行为、同步/阻塞边界
基础 reflect / 方法枚举 / MethodByName→Interface→调用 / Call/MakeFunc / 动态类型 精确数据需求与完整活能力;TinyGo 不支持格标“不支持”,不参与成功程序大小排名
JSON / template / 文件 / timer / channel / goroutine 宽于常量格式的闭包,防止仅消除最简单 fmt 调用
panic/nil/bounds / Caller/Stack/FuncForPC / profiling / O0/O2 debug panic 值、inline/alias/ICF/closure ownership、保真 PCLN 与最终 DWARF
分配/GC/finalizer/weak / C malloc/free / callback / TLS / 多 worker 活根、线程安全、STW、安全点、EH 及异步/同步 C/JS 回调
缺失 C metadata / 未知 host / 新导出 / dynamic callback / cache warm 保守回退、新根不能遗漏、缓存不使用旧 plan

TinyGo 主表使用默认支持的 GC/scheduler/panic,不采用 gc=leaking、scheduler=none、panic=trap 来人为降低参考值;限制功能的诊断单列。双方程序在相同输入下执行并核对输出、返回值及副作用。输出流差异等既有 ABI 约束明确记录,不为了大小修改 LLGo 行为。

W32 在 WAMR 执行、保留默认 threads/GC/EH;浏览器 J32/J64 在 Node 与真实 Chromium 中验收 main thread/worker/跨 worker 对象及失败路径;embedded 至少有对应硬件或适用 emulator 的运行验证,ESP32-C3 无硬件结果不能称完整验收。通用 compiler/runtime 变化还需 native pthread/thread smoke,无调度模型替换。

PR CI 和完成判定

  • PR 就运行体积脚本及打包检查,跳过发布上传;固定 target/toolchain、输出路径、源码、flags、archive 哈希及实际变换阶段。保存最终产物、机器可读列表、linker map/匹配的最终符号诊断和保留原因。
  • 度量验收覆盖冷/热/缺摘要旧缓存、非 LTO/ThinLTO/Full LTO、strip、inline/alias/ICF、Wasm 后处理和固件格式转换。各格式适用组合核对最终字节对账、无重复统计和归因完整度;阶段不同的 IR 不作同口径比较,启用报告不改变 Code/Data,也不恢复已释放 backend 的常驻内存成本。
  • 轻量主矩阵逐 PR 执行;昂贵消融/扩展矩阵在相关 PR 和计划运行中执行,合入前必须有本组所需证据。历史数据保留,新的基线只在显式重测记录中更新。
  • 报告 B0、当前累计、TinyGo、比值、剩余差距;预算回退必须显示。两个编译器都改变版本时重新匹配基线,不允许只抬 TinyGo 目标来“修复”回归。
  • 正确性先于默认启用。若删除外部根、元数据或 formatter 导致一个支持用例失败,这一优化未完成,即使 Hello 体积达标。
  • 本 issue 的完成条件:支持的主要平台三列达到冻结目标,动态/扩展用例保持正确,资源/Flash/RAM/编译与运行代价齐全,可重复 PR CI 形成持续门槛。达不到的格子必须有实测差距、下一轮明确方案并保持开放;不以“4 个 PR 已合入”替代验收。
复现实验的关键命令、变量与诊断限制

以下变量应指向对应快照的 checkout、用 go build -tags=dev ./cmd/llgo 构建的 llgo、以及 pinned Binaryen;不要混用 main 与 #2669 的 LLGO_ROOT。构建按项目现有 toolchain 安装流程准备 target libraries/libffi。

# 在对应 LLGo checkout 执行;OUT 是已有输出目录
LLGO_ROOT="$PWD" "$LLGO" build -target=wasi -o "$OUT/println.wasm" ./benchmark/binary_size/println
LLGO_ROOT="$PWD" LLGO_WASI_THREADS=1 "$LLGO_THREADS" build -target=wasi -deadcodedrop -o "$OUT/println-threads.wasm" ./benchmark/binary_size/println
"$WASMOPT" "$OUT/println-threads.wasm" -Oz -o "$OUT/println-threads-oz.wasm"
iwasm --stack-size=8192000 --heap-size=8000000 "$OUT/println-threads-oz.wasm"

# 诊断 strip/re-encode 对照,不将其解释成全部为 debug 收益
"$WASMOPT" "$OUT/println-threads.wasm" --strip-debug --strip-producers -o "$OUT/println-threads-strip.wasm"

# Emscripten 指向同一个 llgo-v132.3 根(其下有 bin/wasm-opt)
export EM_BINARYEN_ROOT="$BINARYEN_ROOT"
LLGO_ROOT="$PWD" "$LLGO" build -target=wasm -o "$OUT/println.mjs" ./benchmark/binary_size/println
node targets/emscripten-runner.mjs "$OUT/println.mjs"

# 对 J32 baseline wasm 的独立后优化;相邻 mjs 同名保留
"$WASMOPT" "$OUT/println.wasm" --enable-bulk-memory \
  --enable-nontrapping-float-to-int --enable-mutable-globals \
  --enable-sign-ext --enable-reference-types --enable-exception-handling \
  -Oz -o "$OUT/println-oz.wasm"

# TinyGo:未指定 scheduler/gc/panic 的限制选项
tinygo build -target=wasi -no-debug -o "$OUT/tinygo-println.wasm" ./benchmark/binary_size/println
tinygo build -target=wasm -no-debug -o "$OUT/tinygo-println-js.wasm" ./benchmark/binary_size/println

# 符号诊断版本额外保留 names,大小以无诊断版本为准
"$WASMOPT" "$OUT/println-threads.wasm" -Oz -g -o "$OUT/println-named.wasm"
llvm-nm -S --size-sort "$OUT/println-named.wasm"
wasm-objdump -h "$OUT/println-named.wasm"
llvm-size -A "$OUT/firmware.elf"

对 cprintf/fmtprintf 重复同样矩阵;TinyGo 的 cprintf 改用第 1 节 C shim。浏览器导出实验通过 emcc wrapper 仅替换 -sEXPORT_ALL=* 和 -sEXPORTED_RUNTIME_METHODS=*,最终输出保持相同 basename。嵌入式实验使用目标配置副本,不能用随意更换 board/library 的结果充当 _printf_float 单项收益。

TinyGo 诊断限制对照:WASI cprintf/println 在 -scheduler=none -gc=leaking -panic=trap 下为 7,540 / 5,884 B;fmtprintf 在 -gc=leaking -panic=trap 下为 26,403 B,但 -scheduler=none 构建因 Go 1.27 time/sleep.go 使用 goroutine 而失败。这些不作为默认目标;它们改变调度、内存回收或 panic 行为。TinyGo options

本轮产物、日志、section/symbol 报告和 JSON 结果已保存在调研工作目录;本 issue 的数值表完整记录主要结果。后续基线 PR 应将可重复脚本和机器可读结果纳入仓库/CI artifact,避免依赖个人本地目录。

8. 补充调研:TinyGo 的反射能力差异及 LLGo 消融(2026-09-26)

8.1 TinyGo 确实少了多项反射能力,但并非只对 wasm/嵌入式关闭

核对本轮实际使用的 TinyGo v0.42.0,src/reflect 是基于其 internal/reflectlite 的重实现。下列多个 API 是共用源码中的 panic stub,没有按 wasm/embedded 开关保留另一份完整实现。此前 ESP32-C3 使用的 v0.39.0 中也存在相同的 Call/MakeFunc/方法反射限制;本轮没有声称在 MCU 硬件上运行过这些测试。

新建独立探针,先用 Go 1.27 核对预期,再运行 TinyGo WASI/浏览器产物和 LLGo W32 threads。TinyGo WASI 的 16 个定向探针中,5 个通过、11 个因所调用 API 未实现而 panic;这是针对限制选取的探针集合,不是整个 reflect 的通过率。浏览器另跑 6 个代表性探针。LLGo 的 11 个探针中 10 个通过,另一个暴露桥接启用条件的问题,见 8.5。

能力 / 探针 TinyGo 0.42 WASI 实测 TinyGo 0.42 浏览器实测 LLGo #2669 W32 实测
TypeOf/ValueOf、结构体字段/tag、字段读写 通过 通过 通过
New、MakeMap、MakeSlice、MakeChan,再经普通 Go 操作使用 通过 通过 通过
Implements、AssignableTo、NumMethod、普通接口调用 通过 通过 通过
Value.Call 未实现,panic 未实现,panic 通过
MakeFunc → Interface → 普通 Go 函数调用 未实现,panic 未实现,panic 通过
Value.MethodByName → Interface → 普通方法值调用 未实现,panic 未单独执行;同一 stub 失败;增加显式反射 Call 后通过
Type.MethodByName / 方法签名 未实现,panic 未单独执行 通过
函数类型 NumIn/NumOut/In/Out/IsVariadic NumIn 即 panic;其他项源码为 stub 未单独执行 通过
StructOf → New → 字段写入 未实现,panic 未单独执行 通过
SliceOf、FuncOf 各自未实现,panic 未单独执行 本轮未测
通过 Value.Send/Recv 操作 channel Send 即 panic 未单独执行 通过
reflect.Select 未实现,panic 未单独执行 本轮未测
reflect.NewAt 未实现,panic 未单独执行 本轮未测
Slice3 的合法 slice 操作 通过 未单独执行 本轮未测
fmt:Stringer、整数、浮点、slice、结构体组合输出 通过 通过 通过

CallSlice、Value.Method、Type.Method、ArrayOf、MapOf、ChanOf 等限制还由源码确认,本轮未逐一新增运行探针。不要把 NewAt 的错误文本误读成 New 不可用,也不要仅凭 grep 出现 unimplemented 就判定整个函数未实现:TinyGo 的 Slice3 对合法 slice/array 路径有实现。

来源:reflect/value.go、reflect/type.go、reflect/makefunc.go、internal/reflectlite/type.go、v0.39 MakeFunc。官方 language support 也说明反射为重实现且并不完整;逐项结论以固定版本源码和执行结果为准。

这些结果要求重新区分:语言/库能力差异、未使用能力未被正确裁剪、同等功能实现效率差异。一个程序只用基础反射,LLGo 可以保留完整 reflect 的可用性,同时通过可达性只链接基础部分;“支持完整 reflect”本身不是所有程序必须付出全部代价的理由。

8.2 直接禁用高级反射,到底能节省多少

在独立 worktree、同一 #2669 commit、相同 compiler/flags/输出流程下做诊断性改动。全部使用 DCE + llgo-v132.3 最终 -Oz,并在 WAMR 验证 Hello 输出。

三个改动层级:

  • 公共 API stub:Call/CallSlice/MakeFunc/Method/MethodByName 的函数体替换为 panic;没有改变 LLGo 类型布局。
  • 内部调用链隔离:在上一层上,切断 makeMethodValue、makeFunc、callWasmBridge,并移除 call_bridge_wasm.go 的桥接函数指针 init 根。
  • 扩大受限 API 范围:再对动态类型构造、函数签名查询、反射 channel 操作设置 stub。New、基础字段/容器反射等保留;未重写为 TinyGo 的元数据布局,也不是完整的 TinyGo 兼容 profile。

这些是功能缩减诊断,不作为默认实现;该实验不能证明它们对任意程序语义等价。

本轮同目录对照:最终 wasm(B) cprintf println fmtprintf
原始实现,DCE + Oz,保留 PCLN 106,674 106,413 1,164,839
仅公共反射 API stub 未单独重编译 未单独重编译 1,164,839
内部调用链隔离 106,674 106,413 1,132,220
再扩大受限 API 范围 106,674 106,413 1,132,204
最后一行相对本轮 baseline 减少 0 0 32,635(2.80%)
原始实现,额外 none-PCLN 诊断 不重复列举 不重复列举 814,871
扩大受限 API 范围 + none-PCLN 不重复列举 不重复列举 782,236

本轮独立目录重建的绝对大小与第 2 节略有差异;上表全部差值使用同目录 baseline,不能跨表相减。仅公共 stub 的 fmtprintf Code 字节与 baseline 相同;保留 PCLN 时元数据内容有变化,但长度相同,因此不声称整份 wasm 逐字节一致。

fmtprintf 的 Code 从 523,571 → 493,057 B,Data 从 638,697 → 636,601 B。额外删除 PCLN 后也仍有 286,633 B Data。这说明:

  1. 多个未使用的公共 API,本来已不贡献实质代码大小;简单提供一个“把 API 改成 panic”的开关没有解决主要问题。
  2. 基础 Value.Interface/Convert 对方法值的通用处理、内部闭包和初始化根,会间接保留一部分动态反射实现。应改进能力分析和依赖组织。
  3. 32,635 B 不是完整反射能力差异的总成本或上界。本实验仍保留 LLGo 的类型/方法数据结构、接口实现、stdlib 和其他 runtime 依赖,不能等同于将 LLGo 改成 TinyGo。
  4. 该收益与第 5 节已有的反射可达性/去虚化估算重叠,不能再额外相加。即使做了这些功能缩减,并额外去掉 PCLN,也不能据此预计已接近 TinyGo 的 84,833 B。

8.3 更大的新证据:类型描述符和 Unicode 数据

继续取得本轮原始实现 DCE + none-PCLN、Binaryen 之前的 linker map。按输入 .rodata.* / .data.* section 计数,每条输入 section 只计一次,排除其下重复列出的符号、BSS 和地址空洞:

数据类别:链接阶段归因(B) cprintf println fmtprintf 解释
_llgo_ 命名类型描述符及其内联数据 未统计 未统计 123,416 fmtprintf 共 1,004 个,含类型信息/字段/方法相关数据,不等于纯反射额外成本
Unicode 命名数据 未统计 未统计 85,374 字符分类/转换等静态表,需结合初始化可达性分析
匿名常量、元数据、字符串 未统计 未统计 39,228 本轮未全部分类
其他命名数据 未统计 未统计 28,364 含 internal/strconv.pow10Tab 11,136 B 等
runtime 命名数据 未统计 未统计 7,448 不把全部归为 reflect
已归因输入 section 合计 未统计 未统计 283,830 与最终 Data 存在对齐、合并、重编码等差异

代表性类型描述符:reflect.Value 4,376 B、*reflect.Value 4,344 B、*time.Time 2,544 B、*internal/poll.FD 2,304 B。这些是类型描述符,不是相应 Go 值或 struct 的 Sizeof。

LLGo 的 ssa/abitype.go 为 uncommon type 生成附带的 Method 数组,包含方法名字、函数类型及调用入口。TinyGo 的 interface-lowering.go 则会将已知接口断言转为 type-ID 比较、将接口调用降为已知实现分派;没有剩余动态 Implements/AssignableTo 使用时移除方法集合,否则按可能满足的接口签名筛选。这是编译器和元数据表示层面的差异,靠公共 API stub 无法复现。

上面的 123,416 B 不能整体当成可删收益:GC、接口相等、类型断言和基础反射仍需要一部分信息;85,374 B Unicode 也不能只因当前样例未显式 import unicode 就直接删除。两组可达性要结合全局初始化、接口调用、格式化参数一起分析。

8.4 对方案与收益估算的修正

优先研究“基础值检查 / 方法调用与函数构造 / 动态类型构造 / 完整诊断元数据”的依赖分离,默认保留 API 兼容性。对于封闭程序,依据实际入口、方法值来源、类型流和外部回调根,仅保留需要的部分;库构建或未知外部入口保持保守。不能仅用“有没有 import reflect”判断,也不能删掉普通 Go Stringer/error 接口分派。

新一轮验证目标:W32 预计减少 KiB cprintf println fmtprintf 与已有估算的关系
无动态反射调用时,自动消除内部闭包/初始化链 0 0 10–30 32,635 B 功能缩减实验提供参考;保真实现仍未完成。计入原去虚化/可达性项目,不能重复累加
类型描述符和方法数据的存活性/表示优化 1–3 1–3 40–80 以 123,416 B 链接阶段预算约束;低确定性,替代第 5 节原先 20–60 KiB 的探索区间,仍与类型流/init 重叠
未使用 Unicode 表及其初始化一起裁剪 0 0 已包含于原 40–100 新确认 85,374 B 数据及约 51 KB init Code;不是再增加一份可累加收益,需证明可达性并扣除替代数据

完整 API 不能无条件都绑定到同一组重型闭包/方法元数据。类型/接口的基本布局和 GC 契约保持稳定,反射扩展数据按需要生成;后端通过能力描述接入,不为 wasm、embedded、native 分别维护不兼容的接口语义。显式受限 profile 可以另行讨论,但本次证据不支持默认禁用高级 reflect 来追数字。

比较矩阵增加三个层次:

  • Hello/基础值输出:继续以 TinyGo 当前大小为优化目标,研究如何不为未使用的动态功能付费。
  • 双方都支持的反射子集:字段/容器操作、接口判断、复合 fmt;相同输入和结果下比较大小。
  • 动态调用/类型构造等高级能力:TinyGo 失败的格子标为“不支持”,不能把其短小的 panic 产物当成成功程序参与大小排名。

此前对“全部实施后”的 450–750 KiB 等区间只是低置信的组合规划假设,不是保留完整 reflect 的固有下限。这次实验修正成本归因,也提供更具体的元数据优化入口;尚不能据此宣布达到或达不到 TinyGo。后续应以能力分层和同基线组合消融替换这些粗区间。

8.5 历史正确性前置项:方法值调用漏启用 wasm bridge

在未修改的 #2669 commit 上,以下程序通过编译,但 WAMR 报 Exception: indirect call type mismatch:

package main

import "reflect"

type T int

func (t T) Add(x int) int { return int(t) + x }

func main() {
    m := reflect.ValueOf(T(40)).MethodByName("Add")
    if m.Interface().(func(int) int)(2) != 42 {
        panic("wrong result")
    }
    println("PASS")
}

实验控制:

  • 不启用 DCE 时也失败;Binaryen -Oz 前后都失败,不能归因为本次 size pass 或 DCE。
  • 在原调用前增加 reflect.ValueOf(func() {}).Call(nil),原方法值调用通过。
  • 将调用改成 m.Call([]reflect.Value{reflect.ValueOf(2)}),也通过。
  • 同轮普通 Value.Call、MakeFunc、Type.MethodByName、函数签名和 StructOf 探针通过。

这与 isWasmReflectBridgeName 只识别 Call/CallSlice/MakeFunc/Seq/Seq2 的启用逻辑一致,推测遗漏了通过方法值进入桥接的路径;具体修复仍需验证。应先把该路径纳入正确性回归,再扩展精细能力裁剪,不能在有遗漏的入口识别上继续激进删代码。

本轮试验代码只在独立研究 worktree 修改;完成消融后已恢复该 worktree 的原始源码。完整探针、构建/运行日志、JSON、linker map 与本节报告保存在本地研究目录,后续统一纳入 size/reflect CI 工作,不提交 panic stub 为生产方案。

9. llgo build -size / TinyGo -size=full 实测审计(2026-09-26)

历史审计结论:该 LLGo 快照的报告还不能作为 Wasm/嵌入式体积优化的验收依据;TinyGo 的 Wasm 内置报告也不是最终产物报告。

沿用前述 LLGo 快照;2026-09-26 最新 main 6cc99232819f15c2b8b7960a95409c465791d3ad 的 size_report.go 与审计版本逐字相同。Wasm 为 Go 1.27 / LLVM 22.1.8 / Binaryen llgo-v132.3 / TinyGo 0.42 编译器;本机 TinyGo C 库缓存含 0.39 的 DWARF 源路径,已记录 archive 哈希,不能称为全部 C 库重建的干净 0.42 发布基准。简表将相应路径行合并为 C wasi-libc。

嵌入式本轮明确采用 LLGo -target=esp32c3 + board init 与 TinyGo 0.39 -target=esp32c3-12f / Go 1.25,newlib-esp32 与 Picolibc 不同,板级配置也不同;本轮 LLGo 数字不替换前轮另一配置的历史结果。12 个 Wasm 产物分别通过 WAMR/Wasmtime/Node 输出核对;无 MCU 硬件运行验证。

原始 stdout/stderr、命令、JSON/CSV、完整包及最终函数列表保存在本轮 size-report-20260926 审计目录;这里摘录可独立审阅的数值和证据。后续基线 PR 应把脚本及 CI artifacts 纳入仓库。

复现入口:LLGo 使用 build -size -size-level=package -size-format=json,另跑默认 module/full;TinyGo 分别用 build -size=full 与 build -no-debug -size=full。捕获 wasm-opt 输入/输出,以独立 section 解码对账;保留 names 的 LLGo 诊断版与正式版逐个核对非 custom section payload SHA-256。三个样例都一致。

9.1 准确性与完整性

项目 LLGo TinyGo
CLI / 层级 -size,-size-level=module/package/full,text/JSON;module 是 Go module,并非包 -size=short/full/html;full 主要按源码包和 C 库分组
Wasm 读取 WASI 调用 llvm-readelf --all,LLVM 22 本机崩溃;浏览器把 .mjs 当对象文件读取 能读取 Wasm;但 loadProgramSize(result.Executable, ...) 读取优化前文件
ELF section 用名字子串判断,不读取 SHF_ALLOC / SHF_EXECINSTR / SHT_NOBITS 0.42 按 ELF flags/type 分类,覆盖更好;仍有 init-array、别名保留区边界问题
函数/包归因 按相邻符号地址相减,忽略 ELF st_size/type;局部标签会截断函数 DWARF 行表 + 符号大小;能分出类型表、接口分派、C 库、padding、unknown
无法归属 有 unknown,但大量误归属看起来仍然“有名字” 显示 unknown;-no-debug 会警告,本轮 Wasm 包列表基本全部 unknown
完整交付体积 缺最终文件/容器开销/JS 清单 Flash 同样不是 Wasm 文件大小,也不含 JS runner
RAM Data+BSS,不包含完整堆栈/动态运行峰值/多 worker 成本 Wasm BSS 包括未初始化线性内存空隙;Data+BSS 是初始线性内存,不是静态变量或运行峰值
失败行为 打印 Warning,构建仍返回 0;CI 可能以为已经得到报告 0.39 C 样例 -size=full 在 findPackagePath panic;去掉 -size 可正常编译

实测例证:

  • LLGo ELF _dtoa_r 的 st_size 是 3,248,-size-level=full 只记 78。局部 .L0 等标签分割了函数范围,package 输出产生 L0 桶 15,168 / 26,150,不能据此决定哪个包该裁剪。
  • parseNameField 在第一个 ( 截断符号名,Go pkg.(*T).Method 在 full 输出中丢失接收者和方法名;本轮出现 .../runtime.、.../tinygogc. 等合并桶。
  • LLGo 本轮 cprintf/println 的 Flash 报告为 22,496 / 39,286,独立按有文件内容的 SHF_ALLOC section 求和为 22,616 / 42,850。差值 120 / 3,564 正好是 .eh_frame。不是优化收益,而是漏报。
  • TinyGo 0.42 的 ELF 读取器应用于上述 LLGo 文件也漏掉 4 B SHT_INIT_ARRAY,所以不能直接照搬为“正确实现”。TinyGo 0.39 println 的 RAM 是 8,838 B;0.42 读取同一 ELF 是 8,344 B,少了非 writable NOBITS dummy 的 494 B。后者仍包含 .iram_dummy 4,172 B,它与另一地址空间的 stack/BSS 区域对应,不能机械当作另一份物理 RAM。
  • LLGo llvm-readobj --sections 可以读取本轮 Wasm;因此 readelf --all 的崩溃不能解释成 LLVM 完全不支持 Wasm。LLGo 还需要 Wasm 专用分类,不能只换一组选项。

源码:LLGo size_report.go、TinyGo build.go、TinyGo sizes.go。

9.2 TinyGo 的内置列表与最终产物

先用默认 debug 信息运行 -size=full 获取包归因;这是 Binaryen 之前 的 code+rodata+data:

TinyGo WASI 包(节选) cprintf println fmtprintf
runtime 8,562 8,562 14,239
time 0 0 14,032
internal/reflectlite 0 0 11,062
C wasi-libc 3,369 1,415 1,718
internal/task 1,003 1,003 1,501
Go types 0 0 2,005
fmt 0 0 459
全部包合计 13,226 11,224 51,404

不能把 fmt 的 459 B 当成 fmt 的全部成本。全程序优化、内联、接口分派和 DWARF 行归属会把相关代码记在 runtime、time、reflectlite 等处。优化决策仍需保留原因/调用路径和消融实验。

在同一无调试构建中交叉核对,避免把 debug 与 release 混比:

TinyGo WASI 指标 cprintf println fmtprintf
-no-debug -size=full 报告 Flash,优化前 11,795 10,023 47,068
最终 Code payload + data segment 内容 23,306 18,226 81,069
最终 .wasm 文件 25,346 19,922 84,833

第二行减第一行是优化、Asyncify 和编码的净变化;第三行还包括其他 section、data segment 编码、name/producers 等,不能叫“漏算的代码”。默认 debug 构建也做了最终 DWARF 读取,但其 Code 与无调试构建不相同;该列表在本地审计产物中标为诊断版本,不能冒充无调试产物的精确包分布。

9.3 最终产物与模块分布

产物/指标 cprintf println fmtprintf
LLGo W32 .wasm,PATH 中固定 Binaryen,未启用 Go DCE 107,155 106,895 1,904,792
TinyGo WASI .wasm,无调试 25,346 19,922 84,833
LLGo 浏览器 .wasm 142,921 142,157 3,184,232
TinyGo 浏览器 .wasm,无调试 27,638 21,281 159,180
LLGo ESP32-C3 有文件内容的 alloc sections 22,616 42,850 编译失败
TinyGo ESP32-C3 同一 Flash 口径 4,198 4,128 9,152

浏览器还需计入本轮 LLGo .mjs 73,558 / 73,558 / 117,986 B,或 TinyGo 共享 wasm_exec.js 17,089 B。运行 ABI、线程/异常/反射能力和 C 库不同。C shim 的常量 printf 可以折叠为 puts,不能代表真实动态 printf。

LLGo Wasm 内置报告失败,以下是独立分析最终函数体,不是伪造的 -size 输出。诊断版仅保留 name section,三个样例所有非 custom section payload 的 SHA-256 均与原产物相同。

LLGo W32 最终函数归属,Code 字节节选 cprintf println fmtprintf
LLGo runtime 60,573 58,013 107,044
reflect 0 0 276,887
time 0 0 135,906
internal/poll 0 0 134,458
fmt 0 0 128,717
os 0 0 76,313
unicode 0 0 51,195
C/ABI/other 29,812 29,796 29,423
完整 Code section,含未列项/无名函数/长度编码 93,410 93,181 1,217,749
Data 实际初始化内容,未按包强行分摊 12,349 12,313 679,921
TinyGo WASI 最终函数归属,Code 字节节选 cprintf println fmtprintf
runtime 13,700 11,950 21,928
time 0 0 20,248
internal/reflectlite 0 0 17,607
C/ABI/other 8,795 5,503 6,635
完整 Code section,含未列项/无名函数/长度编码 22,970 17,918 75,623
Data 实际初始化内容 336 308 5,446

这些是函数存放归属,跨函数内联后的逻辑来源不保证与符号名前缀一致。所有 section 大小与文件长度相加核对、所有包行与总计核对、函数桶与 Code payload 核对均通过。

9.4 修正先前的 W32 结论

仅改变 PATH 中是否可找到 wasm-opt,本轮 cprintf 为 147,450 → 107,155 B。named-tools wrapper 捕获到 Clang 的真实命令为 wasm-opt input.wasm -Oz -o input.wasm。此前“W32 缺少最终优化步骤”过于绝对:LLGo 的显式 postlink 流程只管 Asyncify,W32 依赖 Clang 的隐式优化及 PATH。

应统一受控流程、固定 Binaryen 版本、记录实际执行阶段,并避免重复执行。已通过 Clang 运行过 -Oz 的构建,不能再领取先前约 40 KB / 431 KB 的优化收益。历史无 PATH 控制基线保留为实验记录。

9.5 优化优先级

  1. 先修度量。 Wasm 专用读取、使用最终 wasm 路径;按 ELF flags/type/st_size 分类,剔除局部标签干扰并保留完整方法名;处理 init-array/unwind、共享内存和地址别名。输出 stage、tool/flags、文件与附属资源、初始化数据、静态零区、stack/heap reserve、unknown/padding。报告失败必须有机器可判定状态,不能默默产出缺行基线。
  2. 简单程序先处理 runtime 与 C 格式化依赖。 LLGo cprintf/println 最终 Code 约 93 KB,其中 runtime/ABI 占约 60 KB,C/ABI 约 30 KB。拆开最小输出、通用 printany、panic 诊断、GC/分配路径;保持需要时的 C printf 能力。嵌入式 _dtoa_r、强制 _printf_float 和软浮点依然是主要证据,不能从错误的 78 B 判断它不重要。前轮移除强制浮点引用的实验节省 16,520 / 13,854 B,但属于显式能力/依赖变化,不保证所有应用同样获益。
  3. fmt 优先做可达性、方法集和初始化裁剪。 reflect、time、poll、fmt、os 是明确的大头。前轮 W32 DCE+Oz 已达到约 1.165 MB;本轮 PATH 中已经执行 Oz 的约 1.905 MB 与它比较时,不再重复算 Binaryen 收益。稳定 DCE 的 C 包、反射方法桥接和接口语义验证后,再考虑默认启用。
  4. 数据与代码分别优化。 fmtprintf 的初始化 Data 679,921 B,TinyGo 为 5,446 B。先前的 PCLN、类型描述符、Unicode 表 link-map 证据仍有效;压缩/按需生成元数据、静态初始化、裁剪未使用的方法集,比再跑一次通用 -Oz 更有针对性。反射公开 API stub 的收益不能代表整个元数据方案收益;不要求 LLGo 默默采用 TinyGo 的功能限制。
  5. 浏览器单独统计 Asyncify 与 JS 导出成本。 使用前/后阶段配对、完整资源清单和同步/异步调用路径;保持 Emscripten C 生态、EH、回调及多 worker 约束。嵌入式先修 fmt 编译阻塞,再给它优化收益估算。

后续 CI 应同时保存机器可读列表与 final artifacts;以同一版本、同一能力配置的最终字节差验收,不以单一包名的涨跌代替消融验证。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions