# 代码生成的 Julia 编程要求
用于代码生成的 Julia 代码,其最核心的要求是类型稳定
代码生成功能对 Julia 代码作出了如下限制:
- 代码生成入口函数是全路径类型稳定的
- 函数运行过程中,不能调用 Julia 编译器
- 函数运行过程中,不能调用来自 libjulia 的 C 函数
此章节将介绍以上三类限制,随后重点展示如何解决它们。通常来说,后两类问题解决起来相对直接,因为它们在理解上比较直观,且与用户自身的代码质量关系不大。
关于类型稳定的第一类问题需要特别关注,因为它是代码生成的核心要求。就现阶段而言,要想编写类型稳定的 Julia 代码并支持代码生成,要求我们对这一概念有基本的认识,并对识别和解决类型稳定问题具备一定的实践经验。
此外,类型稳定不仅有益于代码生成,它还带来以下好处:
- 类型稳定有益于性能优化:类型稳定是高性能 Julia 代码的基石;
- 类型稳定有益于改善用户体验:类型稳定可辅助解决 Julia 首次运行延迟高的问题。
# 编写类型稳定的代码
类型稳定是针对函数调用而定义的概念。类型稳定的定义是,对于一个调用点 (f(arg1, ..., argn)),该调用的函数 (f) 和所有参数 (从arg1到argn) 的类型,
都能在运行之前被 Julia 编译器类型推断为 Julia 具体类型。对于函数调用存在关键字参数的情况,上述定义仍然适用,这是因为 Julia 编译器会将关键字参数变形为位置参数。
下面是类型稳定/不稳定的具体例子:
function example()
bad = Any[1, "2"]
# 类型不稳定:
# 编译器推断 bad[1] 的类型是 Any (非具体类型)
func(bad[1])
good = Int[1, 2]
# 类型稳定:
# 编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
func(good[1]) # 类型稳定
end
# 加 @noinline 是为了后续直观地观察 Julia IR 差异
@noinline func(x::Number) = x + 1
@noinline func(x::String) = length(x)
例子里的 example 函数介绍了两个例子,func(bad[1])是类型不稳定的,func(good[1])是类型稳定的。
可以看到,类型稳定与函数调用点关系更大。即使是编写良好的函数func,根据函数func调用位置(调用点)的不同,既可能类型稳定的,也可能类型不稳定。这就是说,即使函数定义本身是良好的,一旦函数调用点的参数类型无法推断为具体类型,也会导致调用点类型不稳定。
在上面的例子里,我们手工观察代码的类型稳定问题。下面我们介绍类型稳定的检测手段。
# 基于 Julia Typed IR 认识类型不稳定
Julia Typed IR 是 Julia 源码到机器码中间的一环,它可以精确地指示 Julia 代码是否类型稳定。
通过 InteractiveUtils.code_typed 函数或 InteractiveUtils.@code_typed 宏,可以获取函数体特化后的 Julia Typed IR;然后观察 Julia Typed IR、与 IR 中的动态调用和静态调用,就可以确认 Julia 代码的类型稳定情况。
提示
code_typed() 要求提供参数类型信息
获取 Julia Typed IR 的代码通常是 code_typed(函数, (参数1的类型, ..., 参数N的类型)),此处参数类型是必要的。由于 Julia 函数是支持重载的,我们无法仅通过函数名获取 IR,且编译器会根据参数类型来优化、裁剪代码 (类似 C++ constexpr),因此,对于不同的参数类型,即使函数相同,也可以产生不同的 IR 输出。
function example()
bad = Any[1, "2"]
# 类型不稳定:
# 编译器推断 bad[1] 的类型是 Any (非具体类型)
func(bad[1])
good = Int[1, 2]
# 类型稳定:
# 编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
func(good[1]) # 类型稳定
end
# 加 @noinline 是为了后续直观地观察 Julia IR 差异
@noinline func(x::Number) = x + 1
@noinline func(x::String) = length(x)
# @code_typed example()
possible_typed_ir = code_typed(example, ());
typed_ir = possible_typed_ir[1].first;
display(typed_ir)
CodeInfo(
1 ── %1 = Core.tuple(1, "2")::Tuple{Int64, String}
...省略...
13 ─ %31 = Base.arrayref(true, %2, 1)::Any
│ %32 = Main.func::typeof(func)
...省略...
│ invoke %32(%35::String)::Any
└─── goto #16
15 ─ Main.func(%31)::Any
└─── goto #16
...省略...
21 ─ goto #17
22 ┄ goto #23
23 ─ %59 = Base.arrayref(true, %41, 1)::Int64
│ %60 = invoke Main.func(%59::Int64)::Int64
└─── return %60
)
在上面的例子里,我们打印了 example() 产生的 IR,有总共 60 条指令。这个 IR 不表示 example() 这个调用本身的 IR,而是 example 函数体的 IR。
我们简单介绍上面 IR 的打印结构:
IR 左侧为指令序号,从1开始。在这里仅用于定位指令,其具体含义不做深究。
IR 序号的右侧,就是转换自 Julia 源代码的 IR 低级指令。
观察上面这段 IR,可在序号 23 附近找到一个静态调用(即 invoke 指令,详细信息请参见静态调用):
%60 = invoke Main.func(%59::Int64)::Int64
也可在序号 15 右侧找到一个动态调用(形如 f(arg, ...) 的指令,详细信息请参见动态调用):
Main.func(%31)::Any
上述动态调用实际上对应着 example 函数体中的 func(bad[1])。
function example()
bad = Any[1, "2"]
# 类型不稳定:
# 编译器推断 bad[1] 的类型是 Any (非具体类型)
func(bad[1])
good = Int[1, 2]
# 类型稳定:
# 编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
func(good[1]) # 类型稳定
end
一般来说,可以通过观察 Julia Typed IR 来定位动态调用/类型不稳定的来源。但这种方法需要 Julia IR 有一定了解,如果希望对一些简单情况做快速定位,可以对 code_typed() 函数传入关键字参数 debuginfo=:source:
function example()
bad = Any[1, "2"]
# 类型不稳定:
# 编译器推断 bad[1] 的类型是 Any (非具体类型)
func(bad[1])
good = Int[1, 2]
# 类型稳定:
# 编译器推断 good[1] 的类型是 Int64, isconcretetype(Int64) 为 true
func(good[1]) # 类型稳定
end
# 加 @noinline 是为了后续直观地观察 Julia IR 差异
@noinline func(x::Number) = x + 1
@noinline func(x::String) = length(x)
# @code_typed example()
code_typed(example, (); debuginfo=:source)[1]
CodeInfo(
...省略...
@ <path-to-script>\test.jl:6 within `example`
...省略...
15 ─ Main.func(%31)::Any
...省略...
) => Int64
上面 IR 打印出现了源码位置,我们寻找 Main.func(%31)::Any 这一指令往前最近的源码位置:
@ <path-to-script>\test.jl:6 within `example`
这说明动态调用出现在 test.jl 文件的第 6 行,函数名是 example。此处的 test.jl 将随着源文件位置变化,在实际情况下,这是你的 Julia 源文件路径。
# 代码生成入口函数的全路径类型稳定
代码生成要求入口函数是全路径类型稳定的:入口函数内部的所有函数调用,以及这些调用更内部的函数调用,均递归地类型稳定。
当入口函数不满足全路径类型稳定时,代码仍能被正常生成,但执行生成代码至非类型稳定的路径时将抛出异常。
此处以应用程序的 main 入口函数为例:
# main.jl
direct(x::Int32) = x + 2
direct(x::Int) = x + 1
direct(x::String) = string(x, x)
direct(x::Float64) = x * 2
function recursive(bad)
println(direct(bad[1]))
end
# bad:main 入口函数不满足全路径类型稳定,
# 虽然 recursive(bad) 类型稳定,
# 但内部调用 direct(bad[1]) 类型不稳定
function main()
bad = Any[Int32(1), 2, "2", 1.5]
recursive(bad)
end
对于上述程序中的 main 函数,虽然调用点 recursive(bad) 满足类型稳定的条件,但是 recursive 函数内 bad[1] 的具体类型无法推断,此时的函数调用 direct(bad[1]::Any) 是类型不稳定的,因此 main 函数不满足全路径类型稳定。
使用 --deubgtrace 编译选项进行编译,当程序执行到类型不稳定的函数调用时,将报错并给出错误来源。
scc main.jl -o main --debugtrace
.\main.exe # Linux 上命令为 ./main
运行可执行文件,程序执行到类型不稳定的路径时将报错:
Error: ErrorException("dynamic call(Main.direct, %1::Any)")
stacktrace:
<path-to-script>/main.jl:8 at recursive()
<path-to-script>/main.jl:16 at main()
为了让生成的代码能够正常运行,需要消除 recursive 函数内类型不稳定的调用,使其满足全路径类型稳定的条件。其中一种解决方案是,如果 bad[1] 的类型能够提前确定,可以手动标注其类型,使其类型稳定。
# main.jl
direct(x::Int32) = x + 2
direct(x::Int64) = x + 1
direct(x::String) = string(x, x)
direct(x::Float64) = x * 2
function recursive(bad)
println(direct(bad[1]::Int32)) # 如果能够确认此处是 Int32 类型,可以手动标注其类型,使其类型稳定
end
# good:main 入口函数满足全路径类型稳定,
# 因为内部调用 recursive() 递归地类型稳定
function main()
bad = Any[Int32(1), 2, "2", 1.5]
recursive(bad)
end
上述示例手动标注 bad[1] 的类型,将 direct(bad[1]) 转变为类型稳定的函数调用 direct(bad[1]::Int32),此时 main 满足全路径类型稳定的条件,生成的程序能够被正常执行。
在实践中,还有其他各种方案来消除类型不稳定,我们将在下文详细介绍。
# 类型稳定相关的常见问题及解决手段
此小节介绍类型稳定的常见衍生概念与问题,以及面对相应类型不稳定的解决手段。
由于类型稳定在 Julia 中涉及面较广,因此,在有关 Julia 的技术沟通中,常使用“类型稳定”一词来模糊地指代与类型稳定相关的常见问题。此小节明确指出其中实际存在的三类问题:
- 函数的类型稳定问题
- 结构体的类型稳定问题
- 返回值类型的类型稳定问题
上述三类问题虽称为类型稳定问题,但严格来说与类型稳定相差甚远。之所以有这样的命名,正是因为在实践中,它们很容易导致类型不稳定,因此接下来将对它们重点介绍解决方案。
# 函数的类型稳定问题
严格来说,只有调用点才有类型稳定的概念,但使用类型稳定一词来描述函数的定义也很常见。
函数的类型稳定,一般作以下理解:
任给一组符合参数类型标注的、合法的参数类型,特化后的函数体包含的所有调用点,均类型稳定。
以下是一个例子:
f(x::Any) = [x]
上面的函数 f 是类型稳定的,因为对于任意合法的x输入,假设其类型 typeof(x) 为 T,则返回值类型一定是 Vector{T}。
f(x::Any) = [x]
# 使用 code_typed(f, (argtypes..., ))[1].second
# 获取该特化的返回值类型
code_typed(f, (Int, ))[1].second
Vector{Int64}
code_typed(f, (String, ))[1].second
Vector{String}
code_typed(f, (Vector{Int}, ))[1].second
Vector{Vector{Int64}}
提示
typeof() 返回具体类型
对于任意 julia 对象,它一定是一个具体类型的实例,而不可能是一个抽象类型的实例。可以用 typeof() 函数拿到这个具体类型。
可以看到,参数类型标注并不影响类型稳定。
函数的类型不稳定通常由函数各个重载的具体实现导致,以下是一个例子。
function f(x::Any)
global unknown_var
unknown_var + x
end
此处使用了全局变量 unknown_var,它的类型未知,因此无论 f 输入的参数是什么类型,函数体内的调用点 unknown_var + x 都是类型不稳定的。
此外,在实践中不需要、也不可能追求完备的函数类型稳定。在常规情况下,当我们说一个函数类型稳定,并不一定意味着它是严格的函数类型稳定,而是指在“某一类参数输入”的场景下具备函数类型稳定。下面是一个例子:
f(x::AbstractArray{T}) where {T <: Number} = x[1] + x[2]
示意函数的功能是将数组的前两个元素加起来。该例子表面没有什么不妥,但实际上,T 可以不是一个具体类型,也可能不支持加法重载,在这种情况下,函数体就会出现类型不稳定:
f(x::AbstractArray{T}) where {T <: Number} = x[1] + x[2]
abstract type AbsMyNum <: Number end
struct MyNum <: AbsMyNum end
@code_typed f(AbsMyNum[MyNum(), MyNum()])
CodeInfo(
1 ─ %1 = Base.arrayref(true, x, 1)::AbsMyNum
│ %2 = Base.arrayref(true, x, 2)::AbsMyNum
│ %3 = (%1 + %2)::Any
└── return %3
) => Any
可以看到上面的 IR 中有一处动态调用:
%3 = (%1 + %2)::Any
提示
Julia 运算符是函数
a + b 等价于 +(a, b)、a ⊻ b 等价于⊻(a, b)。
在 Julia 中,除短路逻辑运算外,绝大部分运算符都等效为函数调用。该对应是普遍适用的,例如当打印 Julia IR 时,运算符函数调用会打印出更简短的 a + b、a ⊻ b 等形式。
这种处理不仅统一了运算符和函数,也不会带来性能问题,当 julia 代码编译为机器码,a + b 不会开辟函数栈,而只会生成一行汇编指令。
因此,当f的参数x是具体类型 Vector{AbsMyNum} 时,函数f的类型并不稳定。
虽然f存在类型不稳定的情况,但在多数情况下,数字数组的元素类型不会使用抽象类型表示,因此在实践中,上述写法也是合适的。
# 结构体的类型稳定
结构体类型稳定也是常见的类型稳定问题,相比于函数类型稳定,它更为简单:
当结构体类型为具体类型时,其所有字段的类型都是具体类型
以下是一个类型不稳定的结构体:
struct MyType
vec::AbstractVector{Float64}
param::Float64
end
上述 MyType 不是类型稳定的,示例如下:
isconcretetype(MyType)
true
fieldtype(MyType, :vec)
AbstractVector{Float64}
isconcretetype(fieldtype(MyType, :vec))
false
可以使用泛型编写功能等价的结构体,同时满足结构体类型稳定:
struct MyType{FV <: AbstractVector{Float64}}
vec::FV
param::Float64
end
MyType([1.0, 2.0], 1.0)
MyType{Vector{Float64}}([1.0, 2.0], 1.0)
isconcretetype(MyType)
false
isconcretetype(MyType{Vector{Float64}})
true
fieldtype(MyType{Vector{Float64}}, :vec)
Vector{Float64}
isconcretetype(fieldtype(MyType{Vector{Float64}}, :vec))
true
# 返回值类型的类型稳定
返回值类型的类型稳定,或返回值类型稳定,通常用来表达如下含义:
当调用点参数被编译器推断为具体类型时,返回值类型也推断为具体类型。
以下是一个返回值类型不稳定的例子:
function firstcall(val::Float64)
if isinteger(val)
# isinteger 表示浮点数是否为整形
return 1
elseif val > 0.5
return 1.0
elseif val > 0
return "1"
else
return [1]
end
end
secondcall(val::Float64) = println("浮点数")
secondcall(val::Int) = println("整数")
secondcall(val::String) = println("字符串")
secondcall(val::Vector{Int}) = println("整数数组")
function test()
println("输入参数")
val = parse(Float64, readline())
# 此调用点的返回值不能被编译器推断为具体类型,
# 即返回值类型不稳定
ret = firstcall(val)
# firstcall(val) 返回值类型不稳定,导致 secondcall(ret) 类型不稳定
secondcall(ret)
end
实际上,上述调用点 firstcall(val) 是类型稳定的:其函数、参数的类型均已知,且被编译器推断为具体类型。但它的确是返回值类型不稳定的,因为调用点的返回值类型不能被编译器推断为具体类型。
因此,返回值类型的类型稳定与其他类型稳定衍生概念不同,返回值类型的不稳定并不导致当前调用点的类型不稳定,只是会影响后续调用点的类型稳定。
如果代码中出现返回值类型不稳定,根因一般是函数设计问题,常见示例和解决方案在 使用单例消除字符串关键字参数 中详细介绍。
除开函数涉及问题外,返回值类型不稳定的来源也可能是实际的需求。如果目标场景需要返回值类型不稳定,且返回值类型的情况有限,可以通过 if-else 手动拆分,以消除返回值类型不稳定对程序的影响:
function firstcall(val::Float64)
if isinteger(val)
# isinteger 表示浮点数是否为整形
return 1
elseif val > 0.5
return 1.0
elseif val > 0
return "1"
else
return [1]
end
end
secondcall(val::Float64) = println("浮点数")
secondcall(val::Int) = println("整数")
secondcall(val::String) = println("字符串")
secondcall(val::Vector{Int}) = println("整数数组")
function test()
println("输入参数")
val = parse(Float64, readline())
# 此调用点的返回值不能被编译器推断为具体类型,
# 即返回值类型不稳定
ret = firstcall(val)
# firstcall(val) 返回值类型不稳定,
# 通过 if-else 手动拆分,消除返回值类型不稳定
if ret isa Int
secondcall(ret)
elseif ret isa Float64
secondcall(ret)
elseif ret isa String
secondcall(ret)
elseif ret isa Vector{Int}
secondcall(ret)
else
error("未知类型")
end
end
注意,if-else 拆分不适用于所有返回值类型不稳定的场景,它仅适用于返回值类型有严格规范、且取值范围有限的场景,这些场景通常使用了特定的设计模式,如代数数据类型、模式匹配或 Visitor 模式等。
# 消除类型不稳定的一般性实践
# 1. 消除不定返回值类型分支
underlying(x::Complex) = x.re + x.im
underlying(x::Real) = x
# bad:
function f(complex::Complex)
# 假设调用点处参数 complex 是 ComplxF64:
# 则 julia 编译器推断 val 为联合类型 Union{ComplexF64, Float64}
# 补充: julia 编译器事实上可优化小规模的联合类型;
# 而本例是为展示最佳实践做的有意简化
val = abs(complex) < eps() ? complex : real(complex)
# val 推断为联合类型,而非具体类型,此调用点类型不稳定
underlying(val)
end
# good
function f(complex::Complex)
if abs(complex) < eps()
return underlying(complex)
else
return underlying(real(complex))
end
# or
# return abs(complex) < eps() ?
# underlying(complex) :
# underlying(real(complex))
end
# 2. 消除抽象容器
常见的抽象容器误用有以下两种:
Vector{Any}: 通常来自不合时宜地使用[]语法,可改为T[],其中T是元素类型;Vector{Num}:来自数组元素为任意数值类型的需求,该需求通常为伪需求,可以检查代码中使用Vector{Number}的位置,查看函数定义是否可以接受更具体的类型。
注意:抽象容器本身不造成类型不稳定,但后续操作将导致类型不稳定,详见下列代码中example函数。
以下代码介绍Vector{Any}的误用和最佳实践。
# bad
function cumsum(n)
# 空向量,不指定类型,
# 则默认是元素为 Any 类型
xs = []
s = 0
for i = 1:n
s += i
push!(xs, s)
end
return xs
end
# good
function cumsum(n)
# 可使用 T[] 语法
# 指定空向量的元素类型
xs = Int[]
s = 0
for i = 1:n
s += i
push!(xs, s)
end
return xs
end
function example()
# 抽象容器本身类型稳定
xs = cumsum(10)
# 但使用抽象容器将导致后续操作类型不稳定
xs[1] + xs[2]
end
以下代码介绍Vector{Number}的误用和最佳实践。
# bad
function myfunc(xs::Vector{Number})
s = 0.0
for each in xs
s += each
end
return s
end
myfunc([1, 2.0, 3+2im]) # 因为没有匹配到相应参数类型的函数而报错
ERROR: MethodError: no method matching myfunc(::Vector{ComplexF64})
Closest candidates are:
myfunc(::Vector{Number})
@ Main ...
...
# good
# 使用泛型来编写数组的元素类型
function myfunc(xs::Vector{T}) where {T <: Number}
s = zero(T)
for each in xs
s += each
end
return s
end
myfunc([1, 2.0, 3+2im])
6.0 + 2.0im
# 3. 使用单例消除字符串关键字参数
在实际的工程算法开发上,经常使用取值范围有限的关键字参数来指定算法或其他计算细节。例如在 Python/MATLAB/R 等语言中,通常使用一个字符串来表示这样的关键字参数。这种处理看起来是类型稳定的,但由于专业算法层面的因素,当关键字参数的取值不同,函数返回值类型可能也不同,因此导致不规范的 Julia 代码。
下面是一个具体示例:
# bad
algo1(x) = string(x, x)
algo2(x) = 2x
algo3(x) = (x, 2x)
function f(x; algo="default")
if algo == "default" || algo == "algo1"
return algo1(x)
elseif algo == "algo2"
return algo2(x)
elseif algo == "algo3"
return algo3(x)
else
error("未知算法:", algo)
end
end
# 使用 @code_typed 推断类型
(@code_typed f(1, algo="algo2")).second
Union{Int64, Tuple{Int64, Int64}, String}
这种情况下,可以使用单例来作为关键字参数,以避免返回值的类型稳定问题。
# good
abstract type AbsAlgo end
struct Algo1 <: AbsAlgo end
struct Algo2 <: AbsAlgo end
struct Algo3 <: AbsAlgo end
algo1(x) = string(x, x)
algo2(x) = 2x
algo3(x) = (x, 2x)
function f(x; algo::AbsAlgo = Algo1())
if algo isa Algo1
return algo1(x)
elseif algo isa Algo2
return algo2(x)
elseif algo isa Algo3
return algo3(x)
else
error("未知算法", algo)
end
end
# 使用 @code_typed 推断类型
(@code_typed f(1, algo=Algo2())).second
Int64
上面代码保持了初始的程序结构,适用于对旧代码的改进。
但如果在设计阶段认识到各类类型稳定问题,可以采用最佳实践,将代码设计得更简洁、更容易维护:
# best practice
abstract type AbsAlgo end
struct Algo1 <: AbsAlgo end
struct Algo2 <: AbsAlgo end
struct Algo3 <: AbsAlgo end
f(x; algo = Algo1()) = _impl_f(x, algo)
_impl_f(x, ::Algo1) = string(x, x)
_impl_f(x, ::Algo2) = 2x
_impl_f(x, ::Algo3) = (x, 2x)
# 使用 @code_typed 推断类型
(@code_typed f(1, algo=Algo2())).second
Int64
# 避免编译器调用
提示
小节要点
- 代码生成产物不能再调用 Julia 编译器
- 代码生成之前,可以执行任意 Julia 代码、实现复杂的编译期计算
代码生成的目标是输出依赖少、体积小、可被其他项目集成的源码或二进制产物,这与在生成代码中调用 Julia 编译器有本质上的冲突。Julia 编译器依赖多、体积大,且对调用方/接入方有 ABI 要求,无法满足代码生成的目标。
因此,在代码生成中,我们禁止生成后的代码调用编译器。用户触发编译器一般通过 eval 函数,以下是具体示例:
# 模块顶层是编译期
# OK(支持代码生成):eval 在代码生成前调用,不涉及生成后代码的执行
typename = :MyType1
@eval struct $typename
val::Int
end
# OK(支持代码生成): eval 在代码生成前调用,不涉及生成后代码的执行
eval(
Meta.parseall(raw"""
struct MyType2
val::Float64
end
""")
)
function test()
# OK(支持代码生成):生成后代码可调用编译期计算的结果
x = MyType1(1)
y = MyType(2.0)
# Error: 生成后代码不能调用编译器
eval(:($x + 1))
end
# 避免调用来自 libjulia 的 C 函数
libjulia 是 Julia 语言的核心动态库,它体积庞大、依赖大量其他动态库,是代码生成功能需要避免依赖的主要对象。但 libjulia 提供了大量辅助性质的 C 函数来给 Julia 标准库提供方便,因此用户层面的 Julia 代码可能在不经意间依赖这些 C 函数,这就有可能限制代码生成的可用性。
此限制通常可以单点解决,具体做法有:
- 非侵入式补丁 (
SyslabCC.@patch) - 与 C/C++ 联合编译
- 混合使用(1)与(2)
下面通过一个简短但完整的示例来介绍解决方案。
创建 test.jl 文件,内容如下:
function main()
println("> 输入数字:")
val = parse(Float64, readline())
println("input 2 * x = ", 2val)
end
提示
parse 函数
由于 Syslab 代码生成会不断改进对 Julia 标准库的兼容,当前版本的 Syslab 代码生成可能已支持使用 parse 函数解析浮点数。此处的例子是一种示意,用于介绍问题和解决方案。
早期的 Syslab 代码生成不支持调用 Julia 标准库解析浮点数,由于函数不是纯 Julia 实现,代码生成可能会出现报错。
# v0.1.1 版本开始,已支持了 parse 函数解析浮点数,此处仅为示意
scc test.jl -o main.exe --no-blas --static-mingw
# Linux/Windows 可使用统一命令:
# scc test.jl -o main --no-blas --static-mingw
...
Compiler Error(3): ccall on julia's c function which is not implemented: 'jl_try_substrtod'
in the body of typeof(main)
File parse.jl, line 253, in Base.tryparse
File parse.jl, line 372, in Base.#tryparse_internal#509
File parse.jl, line 371, in Base.tryparse_internal
File parse.jl, line 384, in Base.#parse#510
File parse.jl, line 384, in Base.parse
File <path-to-script>\test.jl, line 3, in Main.main
上面的报错信息很明确,Julia 在实现 parse 函数时,使用了 C 函数 jl_try_substrtod,该函数来自代码生成需要规避的 libjulia 库。
为了解决该问题,我们将使用 C 代码来实现浮点数解析的底层功能,并通过非侵入式补丁,让 parse(Float64, str) 接入自定义的 C 函数。
该工作分成两部分,首先使用非侵入式补丁,提供自定义的parse函数。该操作比较简单,创建一个新文件(如patch.jl),并使用SyslabCC.@patch宏标注需要补丁的重载定义即可。
# 文件名 patch.jl
# 只对 parse(::Type{Float64}, s::String)
# 分支使用补丁,对于其他重载,仍使用原生 Julia 实现
SyslabCC.@patch function parse(::Type{Float64}, s::String)
valueRef = Ref{Float64}()
arg1 = Base.unsafe_convert(Ptr{Cdouble}, valueRef)
arg2 = Base.unsafe_convert(Cstring, s)
# GC.@preserve 避免 Julia 回收变量 s,保证 C 代码可以访问 Julia 字符串的内存
GC.@preserve s begin
# 按照 C 风格的错误处理,C 函数返回值通常用来表达
# 函数是否成功执行,而第一个参数是结果的指针
retcode = ccall(
# 定位到自定义的 C 函数:(导出函数名,动态库名称)
(:custom_tryparse_double, "libpatch"),
# 返回值类型
Bool,
# 参数类型
(Ptr{Cdouble}, Cstring),
# 参数值
arg1::Ptr{Cdouble}, arg2::Cstring
)
end
if retcode
error("cannot parse $s as Float64")
end
return valueRef[]
end
其次,根据 Julia 部分的 ccall 调用,提供相应的 C 源码实现,并导出动态库。
// 文件名 patch.c
#include <stdint.h>
#include <stdlib.h>
uint8_t custom_tryparse_double(double *fptr, char *input)
{
char *end = NULL;
// 将解析结果写入 fptr
*fptr = strtod(input, &end);
// 返回 1 表示解析失败
if (*input == '\0')
{
return 1;
}
if (end == input || *end != '\0')
{
return 1;
}
// 返回 0 表示解析成功
return 0;
}
上述 C 源码可使用 gcc 编译器通过以下命令编译出动态库:
gcc -fPIC -shared patch.c -o libpatch.dll
# Linux 命令:
# gcc -fPIC -shared patch.c -o libpatch.so
如果使用其他编译器,需做相应适配处理。
随后我们修改 test.jl,接入上述的补丁,实现自定义的 parse 函数。
# 文件名:test.jl
# @isdefined(SyslabCC) 通过判断SyslabCC
# 模块是否存在,从而判断是否处于代码生成模式
@static if @isdefined(SyslabCC)
include("patch.jl")
end
function main()
println("> 输入数字:")
val = parse(Float64, readline())
println("input 2 * x = ", 2val)
end
最后,我们使用 scc 命令编译 test.jl,输出可执行文件并运行:
scc test.jl -o main --no-blas --static-mingw
.\main.exe # 获取用户输入,解析并乘2:
# Linux 下使用 ./main 运行
> 输入数字:
123.3
input 2 * x = 246.6