Python 生态强大,但解释器执行热循环确实慢。常见的解法是把热点用 C/C++ 或 Rust 重写。

RGC 也提供了原生扩展能力:pyrgc——RGC 官方 Python 绑定框架。把 RGC 函数导出为 CPython 扩展模块,Python 侧直接 import,性能却是原生机器码。

这篇教程从安装开始,走一遍完整流程:脚手架 → 构建 → import 调用 → 类型映射与 str 所有权 → 实战性能对比,最后说明当前限制。所有命令都在本仓库验证过,可以直接复制运行。

本文对应 pyrgc 0.4(配套 rgc v0.0.4)。相比初版:胶水改成 vectorcall,签名与导出集都由编译器发布,str 返回值按所有权分类回收,性能数字全部重测。

1. pyrgc 是什么

pyrgc 把 RGC 函数变成 Python 可以直接导入的扩展模块,实现直接走 CPython C API:

  • 先用 rgc --emit-object --emit-exports 把 RGC 源码编译成 PIC 目标文件,同时取回一份 JSON 元数据:入口文件里每个顶层函数的参数类型、返回类型、是否用 export def 标记为宿主导出,以及 str 返回值的所有权分类;
  • 再为每个导出函数生成 METH_FASTCALL(vectorcall) C 胶水:显式参数个数检查、逐个参数的类型提取、返回值构造、PyMethodDef 方法表、PyInit_ 模块入口;
  • 链接为 .so 共享库(不依赖 libpython,符号由解释器在 import 时解析,与官方扩展构建方式一致)。

两点值得说明。签名和导出集都不是用正则从源码里刮出来的,而是由编译器发布——export def 是编译器认识的声明修饰符,元数据里直接给出 "export": true;类型解析、借用前缀这些只有编译器知道,两边各写一份解析器迟早失同步。胶水走 vectorcall 而不是 METH_VARARGS + PyArg_ParseTuple:后者每次调用都要先建一个 tuple 再解析格式串,改成直接对 PyObject *const *args 调用 PyLong_AsLongLong / PyFloat_AsDouble / PyObject_IsTrue(正是 "L"/"d"/"p" 格式内部用的那批函数)之后,接受的输入和异常类型完全不变,而 tuple 与格式串的开销消失了。第 7 节有实测数字。

2. 安装与准备

先构建编译器(参见 RGC 入门教程):

cd RGLite
cd src
./build.sh        # 产物:src/build/rgc3
cd ..

项目根目录的 build/rgc 可以直接使用。然后从 PyPI 安装 pyrgc:

pip install pyrgc
pyrgc --version
# pyrgc 0.4.0

开发 pyrgc 本身时,从仓库安装可编辑版本:pip install -e pyrgc/。不想安装也能用模块入口:PYTHONPATH=pyrgc python3 -m pyrgc --version。

pyrgc 只负责生成胶水,编译仍由 rgc 完成。它依次在 RGC 环境变量、PATH、仓库 build/rgc 中查找编译器;从 PyPI 安装时没有仓库路径可查,所以要让 rgc 可被发现:

export RGC=$PWD/build/rgc    # 在 RGLite 根目录

3. 快速开始

3.1 生成项目骨架

pyrgc new string_sum
cd string_sum

生成三个文件:

string_sum/
├── README.md
├── pyproject.toml
└── string_sum.rg

3.2 RGC 侧代码

打开 string_sum.rg:

# Computes the sum of two integers.
export def add(a: int, b: int) -> int {
    return a + b
}

# Greets someone by name.
export def greet(name: str) -> str {
    return "hello, " + name + "!"
}

核心就三件事:

  • export def 是编译器认识的声明修饰符:标出这个函数要交给 Python,签名和导出集一起进 --emit-exports 元数据;
  • 想导出的函数必须是入口文件的顶层函数,并且带显式类型注解(胶水需要 C 签名);
  • export 与 public 正交:public 管 RGC 模块之间的可见性,export 管 Python 侧的可见性;public export def 就是两者兼具。只有带 export 的函数会进入扩展模块。

因为它只是元数据,export def 不会生成任何运行时代码,也不需要 import pyrgc 之类的运行时模块——RGC 标准库里没有 pyrgc 的影子。

函数上方的 # 注释会变成 Python 侧的 __doc__,在 Python 里用 help() 或 .__doc__ 就能看到。

3.3 构建

pyrgc build
# built: dist/string_sum.cpython-313-x86_64-linux-gnu.so

构建器自动向上发现 pyproject.toml 里的 [tool.pyrgc] 段,产物在 dist/ 下:

dist/
├── string_sum.cpython-313-x86_64-linux-gnu.so
├── pyrgc_shim_string_sum.c            # 生成的 C 胶水(可阅读)
└── pyrgc_string_sum.exports.json      # 编译器发布的签名与所有权元数据

后两个文件是构建中间产物,故意保留:胶水是普通 C,出问题可以直接读;元数据则是编译器对签名的权威描述。

3.4 导入使用

PYTHONPATH=dist python3 -c "import string_sum; print(string_sum.add(3, 4))"
# 7

Python 侧零配置,就是一个普通的扩展模块:

import string_sum

print(string_sum.add(3, 4))               # 7
print(string_sum.greet("rgc"))            # hello, rgc!
print(string_sum.add.__doc__)
# Computes the sum of two integers.

4. 直接构建已有的 .rg 文件

不想建项目时,可以直接用 pyrgc build <文件> 指定模块名和输出目录。仓库的 examples/pyrgc_demo.rg 是一个完整的示例:

export def add(a: int, b: int) -> int {
    return a + b
}

export def greet(name: str) -> str {
    return "hello, " + name + "!"
}

export def is_even(n: int) -> bool {
    return n % 2 == 0
}

export def dist(ax: float, ay: float, bx: float, by: float) -> float {
    dx = ax - bx
    dy = ay - by
    return sqrt(dx * dx + dy * dy)
}

export def say_hi() -> None {
    puts("hi from RGC")
}

# 没有 export:普通 RGC 函数,不会出现在扩展模块里
def scale_internal(n: int) -> int {
    return n * 2
}

# extern def 与 export def 可混用:puts 来自 libc
extern def puts(s: str) -> int

构建并验证:

pyrgc build examples/pyrgc_demo.rg -n pyrgc_demo -o build/pyrgc
PYTHONPATH=build/pyrgc python3 -c "import pyrgc_demo; print(pyrgc_demo.add(3, 4))"

输出:

7

这个示例同时展示了三个点:

  • dist 用了标准库的 sqrt,返回 float;
  • say_hi 通过 extern def puts 直接调 libc,说明 extern def(FFI)和 export def 可以混用;
  • scale_internal 没有 export,构建产物里就没有它——导出集完全由标记决定,而不是“所有顶层函数”。

5. 类型映射(RGC ↔ Python)

pyrgc 当前支持五种标量类型的双向转换:

RGC Python 参数提取 返回值构造
int int PyLong_AsLongLong(接受 __index__) PyLong_FromLongLong
float float PyFloat_AsDouble(接受 __float__) PyFloat_FromDouble
bool bool PyObject_IsTrue PyBool_FromLong
str str PyUnicode_Check + PyUnicode_AsUTF8AndSize PyUnicode_FromString(owned 时再 free)
None None — Py_RETURN_NONE

这些正是 PyArg_ParseTuple 的 "L"/"d"/"p"/"s" 格式内部调用的函数,所以哪些输入被接受、溢出怎么处理、抛什么异常都和以前一致。参数个数不符时胶水抛 TypeError(add() takes exactly 2 arguments (3 given));类型不符时的措辞也沿用 CPython 自己的(包括把 None 打印成 None 而不是 NoneType 这个特例)。str 参数遇到内嵌 NUL 抛 ValueError,不会静默截断。

5.1 str 返回值的所有权

RGC 没有 GC,str 就是一块 malloc 出来的 NUL 结尾缓冲区。函数返回的指针因此可能是三种东西:静态字符串字面量(在 rodata 里)、调用方自己传进来的参数缓冲区、或者新分配的一块。前两种一旦被 free 就会破坏堆或者 CPython 的字符串缓存,所以由编译器分类,胶水只释放能证明是新分配的那一种,证明不了的一律当作不可释放:

分类 返回表达式 行为
owned 拼接(a + b) 构造完 Python 对象后 free
static 字符串字面量 不释放
borrowed 参数,或参数构成的链 不释放
unknown 其余一切,比如调用返回值 不释放,构建时打印告警

owned 只由 str 类型表达式上的 + 推出——它必然降级成一次新 malloc,而 RGC 没有运算符重载,所以这个推断是可靠的。其余一律归为 unknown 并保留缓冲区:分析宁可漏(继续泄漏),也绝不误判成 owned(堆破坏)。所以本文 greet 里的 return "hello, " + name + "!" 是 owned,反复调用不涨内存;而 return to_string(n) 这种会得到一条告警:

pyrgc: warning: 'opaque' returns a str whose ownership is not provable, so the buffer is not freed and each call may leak; returning a concatenation (`a + b`) makes it owned

拼接链内部的中间缓冲区由编译器自己释放:a + b + c 会分配两块,只有最外层那块逃逸,里层那块在字节拷出后立即 free。所以链再长也不会随调用次数涨内存。

6. 导出规则与项目配置

6.1 导出规则

  • 导出函数必须写在入口文件,用 export def 标记,且带显式类型注解。
  • 支持的类型:int / float / bool / str / None。
  • 导出名即 RGC 函数名;暂不支持改名导出(PyO3 里对应的 #[pyo3(name = ...)] 后续提供)。
  • public 不参与导出判定:它只管 RGC 模块之间的可见性,没带 export 的 public def 不会进 Python 模块。

6.2 pyproject.toml

在项目内 pyrgc build 会自动向上发现 [tool.pyrgc] 段:

[tool.pyrgc]
name = "string_sum"     # 扩展模块名(默认项目目录名)
source = "string_sum.rg"
out-dir = "dist"
# rgc = "build/rgc"     # 编译器路径(默认 RGC env / PATH / 仓库 build/rgc)
# cc = "cc"
# release = true        # false -> rgc -O0
# link-args = ""

build 命令的常用选项:

选项 作用
-n, --name 扩展模块名(默认:源文件名 / pyproject 项目名)
-o, --out 输出目录
--rgc rgc 编译器路径(默认 RGC 环境变量 / PATH / 仓库 build/rgc)
--cc C 编译器(默认 cc)
--debug 不优化构建(rgc -O0)

pyrgc develop 会把构建好的扩展安装到当前激活的 virtualenv,开发时直接 import 即可。

7. 实战:性能对比

7.1 计算密集:递归 Fibonacci

下面用 pyrgc 导出一个递归 fib,看看"用 RGC 写热点"到底能快多少。

新建项目并把 fib_demo.rg 改成:

# Computes the n-th Fibonacci number (recursive).
export def fib(n: int) -> int {
    if n <= 1 {
        return n
    }
    return fib(n - 1) + fib(n - 2)
}

构建并测试:

pyrgc build
# taskset 只是把两次测量钉在同一个核上让数字可复现,换成任意空闲核心都行,不钉也能跑
taskset -c 3 env PYTHONPATH=dist python3 - <<'EOF'
import fib_demo, time

print("fib(10) =", fib_demo.fib(10))
print("fib(20) =", fib_demo.fib(20))
print("fib(30) =", fib_demo.fib(30))

def pyfib(n):
    return n if n <= 1 else pyfib(n - 1) + pyfib(n - 2)

def best(fn, rounds=7):
    ts = []
    for _ in range(rounds):
        t0 = time.perf_counter()
        fn(30)
        ts.append((time.perf_counter() - t0) * 1000)
    return min(ts)

rgc = best(fib_demo.fib)
py = best(pyfib)
print(f"RGC 用时: {rgc:.1f} ms")
print(f"Python 用时: {py:.1f} ms")
print(f"加速比: {py / rgc:.1f}x")
EOF

本机实测(Python 3.13 / RGC v0.0.4,taskset 绑定单核,各取 7 次最小值):

fib(10) = 55
fib(20) = 6765
fib(30) = 832040
RGC 用时: 1.5 ms
Python 用时: 39.0 ms
加速比: 25.8x

同一个递归算法,RGC 快约 26 倍。这就是"语法像 Python、性能像 C"的直观体现——你不需要改算法,只需要把热点函数用 RGC 重写一遍,然后从 Python 正常调用。

绝对耗时对 CPU 频率很敏感:换到本机另一组核心上是 1.5 ms / 40.7 ms,比值 26.9 倍。不绑核直接跑会因为调度在核间跳动而波动更大,所以要看比值而不是绝对值。

7.2 调用密集:单次调用的边界开销

Fibonacci 只跨一次语言边界,量不到胶水本身的成本。真正决定"能不能把细粒度函数交给 RGC"的是每次调用的开销。用 timeit 各跑 200 万次、取 5 轮最小值(含相同的循环开销,可直接横向比较):

调用 pyrgc 纯 Python
add(3, 4) → int 10.2 ns 15.4 ns
dist(1.0, 2.0, 3.0, 4.0) → float 15.2 ns 88.5 ns
greet("rgc") → str 36.0 ns 33.7 ns

add 的 10.2 ns 已经贴住手写 METH_FASTCALL C 扩展的下限(同机测得 10.3 ns),也就是说胶水本身几乎不再产生可测量的成本。dist 这种参数多一点、纯 Python 要算一串浮点的,差 5.8 倍。

greet 是唯一没跑赢的一行,而且差得不多(36.0 vs 33.7 ns):CPython 对 ASCII 字符串拼接有专门优化(单字符缓存 + latin-1 快路径),而 RGC 侧要 malloc + strlen + memcpy 两次。结论很实用——跨语言调用要为计算付费,不要为廉价的字符串操作付费。

顺带验证内存:greet 连调 20 万次,RSS 增长 0.0 MB(0.0 B/call),与纯 Python 一致。这正是第 5.1 节所有权分类要解决的事——返回拼接结果的函数不再泄漏。

8. 当前限制(0.4)

pyrgc 还很年轻,以下限制在后续版本会逐步放开:

  • 只支持入口文件的标量函数;方法、类、容器(List/Map)导出待实现。
  • 导出名固定为 RGC 函数名,暂不支持改名导出。
  • str 参数指针只在调用期间有效,不要把传入的 str 长期持有。
  • str 返回值分类为 unknown 时不回收(见 5.1);改成返回拼接结果即为 owned,不再泄漏。
  • 不支持关键字参数、默认值参数。
  • 无 PyErr 异常传播——RGC 错误建议返回 Result 并在 RGC 层处理。

9. 下一步

  • 完整文档:仓库 docs/compiler/FFI.md(extern def 调用 C 库 + --emit-exports 元数据 + pyrgc 详解)
  • 语言基础:RGC 入门教程
  • 回归测试:tests/pyrgc/suite.rg(由 rack test 运行:构建 + CPython 实际 import 断言)、tests/exports/suite.rg(签名与 str 所有权分类)、tests/unit/str_concat.rg(拼接缓冲区生命周期);pyrgc/tests/ 另有 pytest 单元与端到端测试

pyrgc 以 Apache 2.0 协议开源。欢迎在 GitHub 提 Issue、贡献代码。如果你有现成的 Python 热点函数,不妨试试用 RGC 重写——也许就是几倍的性能提升。