线性求解器与控制流¶
先定义一个把向量映射到向量的算子,再把它交给求解器。 从下面的三步 Poisson 示例开始;更复杂的动态邻域、迭代算法与非线性方程放在后面。
python examples/fem_poisson_minimal.pypython examples/meshfree_linear_solve.pypython examples/nonlinear_solve.py
求解器的原生 Tensor 与 Torch 互操作
kernel 调用两种都接受——src/dst/edge 字段可以直接传 torch
tensor,不需要包装。本页的高级原生
求解驱动器不同:linear_solve / nonlinear_solve 会把迭代循环捕获为
设备端控制流(gf_control.repeat / gf_control.while),因此向量
必须是 tg.Tensor。在边界处用 tg.from_torch(x) 把 torch 存储零
拷贝包进来;只有仍持有 Torch 存储的值才能用 .to_torch() 零拷贝返回。
原生求解结果需要 .to_torch(copy=True),且要求连续存储。
共享存储本身不会把原生梯度图接入 PyTorch autograd。
用邻居求和求解 Poisson 方程¶
目标很简单:在一条线上求解 Poisson 方程, 两端固定为零,中间放 7 个未知节点。
只需要定义“给定 u,如何算出 A(u)”。每个节点读取左右邻居,
自身取两倍,再除以网格间距;迭代循环交给求解器,不需要手写 CG。
下面是完整计算过程。linear_solve 来自仓库中的
examples/solvers.py,不是 tg 的内置 API。
import tiga as tg
from solvers import linear_solve # Helper in examples/solvers.py, not tg API.
class Poisson(tg.MessagePassing):
reducer = tg.sum()
def edge(self, src, dst, edge):
return -src.u
def node(self, dst, incoming):
return 2.0 * dst.u + incoming
# 1. Seven interior nodes; omitted boundary values are fixed to zero.
n = 7
h = 1.0 / (n + 1)
graph = tg.Graph.stencil((n,), ((-1,), (1,)))
kernel = Poisson()
def apply(u):
return kernel(graph=graph, src={"u": u}, dst={"u": u}, edge={}) / h
# 2. Solve A(u) = b. The driver owns the iteration loop.
b = tg.tensor([h] * n, dtype=tg.float32)
u = linear_solve(apply, b, tolerance=1e-6, max_iterations=32)
# 3. Compare the nodal solution with x(1-x)/2.
exact = [0.5 * (i * h) * (1.0 - i * h) for i in range(1, n + 1)]
error = max(abs(a - e) for a, e in zip(u.tolist(), exact))
运行 python examples/fem_poisson_minimal.py,得到:
这里的“无矩阵”指不构造刚度矩阵:Graph.stencil 给出邻接,
edge() 取负的邻居值,node() 加上自身的两倍。
图仍有 CSR 拓扑,省去的是单独的刚度矩阵与逐边刚度数组。
本例使用 tg.Tensor,因为当前辅助求解器捕获的是原生控制流;
这不是普通消息传递必须更换 Torch Tensor 的要求。
为什么这是 FEM?边界怎么处理?
一维均匀网格上的分段线性 P1 有限元
给出以下离散方程。h 是节点间距,b 是单位载荷对应的右端项:
图只存内部节点,越界邻居被省略。被省略的边界值本来就是零,
但 node() 中的对角系数仍是 2,不能改成有效邻居数。
解析解用于检查节点结果:
停止条件、固定迭代与求导
tolerance=1e-6 检查绝对残差范数,max_iterations=32 给出迭代上界;
达到上界不等于保证收敛,因此示例额外检查解析解误差。
固定次数迭代和对载荷求导保留在 进阶示例 fem_poisson.py。 控制流与梯度契约见线性求解器。
动态半径图上的无矩阵线性求解¶
是什么。 这里完全没有网格文件——图是从数据生成的。给定平面上 N 个点,距离不超过截断半径 r 的两个不同点成为邻居(半径图)。在该图上, 示例求解 (m·I + L) u = b,其中 L 是图拉普拉斯算子:对每个点,把它 自身的值与每个邻居的值之差求和。质量偏移 m = 1 保证算子正定, 右端项是周期模式 b_i = 1 + (i mod 3)。
示例把点放在间距为 h 的直线上,并设 r = 1.01·h,因此每个点的邻域 恰好是相邻的两个点,生成的关系是一条路径。
求解器和算子接口与 FEM 情形完全相同:拓扑由 Graph.radius 产生,
关系的实现与复用是 MessagePassing UDF 之下 provider 层的决策。
CG 在一个 gf_control.while 中捕获收敛判断和四个随迭代携带的状态;
用户不需要写任何邻接结构或稀疏矩阵。
class ShiftedRadiusLaplacian(tg.MessagePassing):
"""Apply ``mass * u + sum_neighbour(u_dst - u_src)``."""
reducer = tg.sum()
def edge(self, src, dst, edge):
return dst.u - src.u
def node(self, dst, laplacian, mass):
return mass * dst.u + laplacian
def solve(
points: int = 8,
*,
tolerance: float = 1.0e-5,
max_iterations: int = 32,
):
"""Solve a positive-definite operator on a generated path relation."""
if points < 2:
raise ValueError("points must be at least two")
spacing = 1.0 / (points - 1)
positions = tg.tensor(
[[index * spacing, 0.0] for index in range(points)],
dtype=tg.float32,
) # (N, 2)
# The logical relation remains procedural. Its current physical snapshot
# may be built, streamed, cached, or rebuilt by the selected provider.
graph = tg.Graph.radius(positions, cutoff=1.01 * spacing)
# A MessagePassing kernel bound to a Graph is the linear operator; the
# solver binds the iterated vector to the "u" field each iteration.
apply_laplacian = ShiftedRadiusLaplacian()
rhs = tg.tensor(
[1.0 + float(index % 3) for index in range(points)],
dtype=tg.float32,
) # (N,)
solution = linear_solve(
apply_laplacian,
rhs,
method="cg",
graph=graph,
field="u",
params={"mass": 1.0},
tolerance=tolerance,
max_iterations=max_iterations,
) # (N,)
residual_norm = vector_norm(
apply_laplacian(
graph=graph,
src={"u": solution},
dst={"u": solution},
mass=1.0,
)
- rhs
)
return solution, residual_norm, graph, apply_laplacian
完整源码:examples/meshfree_linear_solve.py(可直接运行)
"""Dynamic-radius matrix-free solve with a bounded device-side CG loop."""
from __future__ import annotations
import tiga as tg
from solvers import linear_solve, vector_norm
class ShiftedRadiusLaplacian(tg.MessagePassing):
"""Apply ``mass * u + sum_neighbour(u_dst - u_src)``."""
reducer = tg.sum()
def edge(self, src, dst, edge):
return dst.u - src.u
def node(self, dst, laplacian, mass):
return mass * dst.u + laplacian
def solve(
points: int = 8,
*,
tolerance: float = 1.0e-5,
max_iterations: int = 32,
):
"""Solve a positive-definite operator on a generated path relation."""
if points < 2:
raise ValueError("points must be at least two")
spacing = 1.0 / (points - 1)
positions = tg.tensor(
[[index * spacing, 0.0] for index in range(points)],
dtype=tg.float32,
) # (N, 2)
# The logical relation remains procedural. Its current physical snapshot
# may be built, streamed, cached, or rebuilt by the selected provider.
graph = tg.Graph.radius(positions, cutoff=1.01 * spacing)
# A MessagePassing kernel bound to a Graph is the linear operator; the
# solver binds the iterated vector to the "u" field each iteration.
apply_laplacian = ShiftedRadiusLaplacian()
rhs = tg.tensor(
[1.0 + float(index % 3) for index in range(points)],
dtype=tg.float32,
) # (N,)
solution = linear_solve(
apply_laplacian,
rhs,
method="cg",
graph=graph,
field="u",
params={"mass": 1.0},
tolerance=tolerance,
max_iterations=max_iterations,
) # (N,)
residual_norm = vector_norm(
apply_laplacian(
graph=graph,
src={"u": solution},
dst={"u": solution},
mass=1.0,
)
- rhs
)
return solution, residual_norm, graph, apply_laplacian
# Inspect: solve()[2].explain(), solve()[0].mlir(),
# solve()[0].generated_code("cpu_loop")
if __name__ == "__main__":
solve()
本机编译产物(Ryzen 7 255 · RTX 5070 Ti 实测)
### ShiftedRadiusLaplacian
backend: cpu:0
provider: tiga-runtime
lowering: gf-tensor-relation-autograd
passes: materialize-fixed-radius-snapshot, capture-message-passing-udf, analyze-reducer-algebra, lower-csr-to-gather-segment, fuse-edge-node-regions
remark: [planning] edge/node UDFs remain in the differentiable Tensor DAG
remark: [planning] gf-tensor-vjp generates CSR gather/segment-sum adjoints
remark: [planning] reducer lowering: builtin-additive-state
variant cache: hits=0, misses=3
### ShiftedRadiusLaplacian
backend: cuda:0
provider: tiga-runtime
lowering: gf-tensor-relation-autograd
passes: materialize-fixed-radius-snapshot, capture-message-passing-udf, analyze-reducer-algebra, lower-csr-to-gather-segment, fuse-edge-node-regions
remark: [planning] edge/node UDFs remain in the differentiable Tensor DAG
remark: [planning] gf-tensor-vjp generates CSR gather/segment-sum adjoints
remark: [planning] reducer lowering: builtin-additive-state
variant cache: hits=0, misses=3
共享求解器语法糖:linear_solve¶
是什么。 上面两个示例都用定常迭代求解 A·u = b:从一个初始猜测出发,
反复应用 A 来缩小残差 r = b − A·u。提供三种方法,由一个 method=
参数选择:
method= |
算法 | 算子契约 |
|---|---|---|
"cg"(默认) |
共轭梯度 | 对称正定 |
"bicgstab" |
BiCGStab | 非对称 |
"richardson" |
阻尼定步长迭代 | 任意算子,最慢 |
Richardson 采用朴素的校正步 u ← u + ω·r。当 A 对称正定时,共轭梯度 做得更好:沿一系列 A-正交搜索方向前进,在迭代之间携带四个状态 ——解 u、残差 r、搜索方向 p,以及内积 ρ = ⟨r, z⟩,其中 z 是经过 可选预条件处理的残差。BiCGStab 以每步两次算子应用为代价放弃了 对称性要求,额外携带影子残差 r̂ = r₀、算子像 v = A·p 和 t = A·s, 以及标量 ρ、α、ω。
solvers.py 用 gf_control 原语组合这些方法,循环写成 @tg.jit 下
普通的 Python for/while。每个示例把算子构造包在一个局部
solve() 里;求解器本身就是一次
linear_solve(operator, rhs, method="cg", …) 调用——没有别的叫
solve 的东西。该模块刻意不属于核心包——是语法糖,不是原语。
examples/solvers.py(完整源码)
"""Matrix-free stationary solvers composed from Tiga primitives."""
from __future__ import annotations
from collections.abc import Callable, Mapping
import tiga as tg
Matvec = Callable[[tg.Tensor], tg.Tensor]
Preconditioner = Callable[[tg.Tensor], tg.Tensor]
def dot(left: tg.Tensor, right: tg.Tensor) -> tg.Tensor:
"""Capture a rank-one Hermitian dot product as Tensor algebra."""
if not isinstance(left, tg.Tensor) or not isinstance(right, tg.Tensor):
raise TypeError("dot operands must be tiga.Tensor values")
if left.ndim != 1 or right.ndim != 1 or left.shape != right.shape:
raise ValueError("dot requires rank-one operands with identical shapes")
if left.dtype is not right.dtype or left.device != right.device:
raise ValueError("dot operands must have identical dtype and device")
return (left.conj() * right).sum()
def vector_norm(value: tg.Tensor) -> tg.Tensor:
"""Capture the Euclidean norm of one real vector.
Complex norm needs a real-valued projection dtype that the minimal Tensor
frontend does not expose yet, so it fails instead of returning a complex
square root with misleading semantics.
"""
if not isinstance(value, tg.Tensor) or value.ndim != 1:
raise TypeError("vector_norm expects one rank-one tiga.Tensor")
if value.dtype.kind != "float":
raise TypeError("vector_norm currently requires a real floating Tensor")
return dot(value, value).sqrt()
def _bind_operator(
operator,
*,
graph: tg.Graph | None,
field: str | None,
src: Mapping[str, tg.Tensor] | None,
dst: Mapping[str, tg.Tensor] | None,
edge: Mapping[str, tg.Tensor] | None,
params: Mapping[str, object] | None,
) -> tuple[Matvec, int | None]:
"""Return ``(matvec, extent)`` for a kernel or plain callable operator."""
if isinstance(operator, tg.Kernel):
if graph is None or field is None:
raise TypeError(
"a MessagePassing operator requires graph= and field= so the "
"solver can bind the iterated vector")
if not isinstance(field, str) or not field:
raise TypeError("field must be a non-empty field name string")
schema = graph.schema
if schema.num_src != schema.num_dst:
raise ValueError(
"solvers require a square relation: graph has "
f"{schema.num_src} sources and {schema.num_dst} destinations")
constant_src = dict(src) if src is not None else {}
constant_dst = dict(dst) if dst is not None else {}
if field in constant_src or field in constant_dst:
raise ValueError(
f"field {field!r} is the iterated unknown and must not also "
"be bound as a constant")
constant_edge = dict(edge) if edge is not None else {}
bound_params = dict(params) if params is not None else {}
def apply(vector: tg.Tensor) -> tg.Tensor:
return operator(
graph=graph,
src={**constant_src, field: vector},
dst={**constant_dst, field: vector},
edge=constant_edge,
**bound_params,
)
return apply, schema.num_dst
bindings = {
"graph": graph, "field": field, "src": src,
"dst": dst, "edge": edge, "params": params,
}
given = [name for name, value in bindings.items() if value is not None]
if given:
names = ", ".join(given)
raise TypeError(
f"{names}= only apply to MessagePassing operators; a plain "
"callable closes over its own constants")
if not callable(operator):
raise TypeError(
"operator must be a tiga.MessagePassing kernel or a "
"Tensor -> Tensor callable")
return operator, None
def _checked_apply(
matvec: Matvec,
vector: tg.Tensor,
extent: int | None,
) -> tg.Tensor:
"""Apply one matrix-free product and validate the contract."""
if extent is not None and vector.shape != (extent,):
raise ValueError(
f"operator expected input shape {(extent,)}, got {vector.shape}")
output = matvec(vector)
if not isinstance(output, tg.Tensor):
raise TypeError("operator must return a tiga.Tensor")
if output.shape != vector.shape:
raise ValueError(
f"operator must be square: input shape {vector.shape} produced "
f"output shape {output.shape}")
if output.dtype is not vector.dtype or output.device != vector.device:
raise ValueError(
"operator input and output must have identical dtype and device")
return output
@tg.jit
def _richardson_loop(matvec, omega, state, rhs, iterations):
for _ in range(iterations):
state = state + omega * (rhs - matvec(state))
return state
@tg.jit
def _cg_fixed_loop(
matvec, prepare, solution, residual, direction, product, iterations,
):
for _ in range(iterations):
applied = matvec(direction)
alpha = product / dot(direction, applied)
solution = solution + alpha * direction
residual = residual - alpha * applied
preconditioned = prepare(residual)
updated = dot(residual, preconditioned)
direction = preconditioned + (updated / product) * direction
product = updated
return solution
@tg.jit
def _cg_tolerance_loop(
matvec, prepare, solution, residual, direction, product,
threshold_squared, max_iterations,
):
for _ in range(max_iterations):
if dot(residual, residual) <= threshold_squared:
break
applied = matvec(direction)
alpha = product / dot(direction, applied)
solution = solution + alpha * direction
residual = residual - alpha * applied
preconditioned = prepare(residual)
updated = dot(residual, preconditioned)
direction = preconditioned + (updated / product) * direction
product = updated
return solution
@tg.jit
def _bicgstab_fixed_loop(
matvec, solution, residual, shadow, direction, velocity,
rho, alpha, omega, iterations,
):
for _ in range(iterations):
rho_new = dot(shadow, residual)
beta = (rho_new / rho) * (alpha / omega)
direction = residual + beta * (direction - omega * velocity)
velocity = matvec(direction)
alpha = rho_new / dot(shadow, velocity)
intermediate = residual - alpha * velocity
second = matvec(intermediate)
omega = dot(second, intermediate) / dot(second, second)
solution = solution + alpha * direction + omega * intermediate
residual = intermediate - omega * second
rho = rho_new
return solution
@tg.jit
def _bicgstab_tolerance_loop(
matvec, solution, residual, shadow, direction, velocity,
rho, alpha, omega, threshold_squared, max_iterations,
):
for _ in range(max_iterations):
if dot(residual, residual) <= threshold_squared:
break
rho_new = dot(shadow, residual)
beta = (rho_new / rho) * (alpha / omega)
direction = residual + beta * (direction - omega * velocity)
velocity = matvec(direction)
alpha = rho_new / dot(shadow, velocity)
intermediate = residual - alpha * velocity
second = matvec(intermediate)
omega = dot(second, intermediate) / dot(second, second)
solution = solution + alpha * direction + omega * intermediate
residual = intermediate - omega * second
rho = rho_new
return solution
def richardson(
operator,
rhs: tg.Tensor,
*,
graph: tg.Graph | None = None,
field: str | None = None,
src: Mapping[str, tg.Tensor] | None = None,
dst: Mapping[str, tg.Tensor] | None = None,
edge: Mapping[str, tg.Tensor] | None = None,
params: Mapping[str, object] | None = None,
iterations: int,
relaxation: tg.Tensor | float = 1.0,
initial: tg.Tensor | None = None,
) -> tg.Tensor:
"""Capture a bounded matrix-free Richardson iteration.
The body is captured once inside ``gf_control.repeat`` and can contain
MessagePassing. It is intentionally fixed-count; use ``cg`` with
``tolerance`` for a device-side convergence test.
"""
matvec, extent = _bind_operator(
operator, graph=graph, field=field, src=src, dst=dst,
edge=edge, params=params)
if not isinstance(rhs, tg.Tensor) or rhs.ndim != 1:
raise TypeError("richardson rhs must be a rank-one tiga.Tensor")
if extent is not None and rhs.shape != (extent,):
raise ValueError(
f"richardson rhs shape must be {(extent,)}, got {rhs.shape}")
if not isinstance(iterations, int) or isinstance(iterations, bool):
raise TypeError("richardson iterations must be an integer")
if iterations < 0:
raise ValueError("richardson iterations must be non-negative")
state = tg.zeros_like(rhs) if initial is None else initial
if not isinstance(state, tg.Tensor) or state.shape != rhs.shape:
raise ValueError("richardson initial value must match rhs shape")
if state.dtype is not rhs.dtype or state.device != rhs.device:
raise ValueError("richardson initial value must match rhs dtype and device")
omega = relaxation
return _richardson_loop(
lambda value: _checked_apply(matvec, value, extent),
omega,
state,
rhs,
iterations,
)
def cg(
operator,
rhs: tg.Tensor,
*,
graph: tg.Graph | None = None,
field: str | None = None,
src: Mapping[str, tg.Tensor] | None = None,
dst: Mapping[str, tg.Tensor] | None = None,
edge: Mapping[str, tg.Tensor] | None = None,
params: Mapping[str, object] | None = None,
iterations: int | None = None,
tolerance: tg.Tensor | float | None = None,
max_iterations: int | None = None,
initial: tg.Tensor | None = None,
preconditioner: Preconditioner | None = None,
) -> tg.Tensor:
"""Capture bounded matrix-free (preconditioned) conjugate gradient.
The operator contract is symmetric positive-definite, as in scipy; the
sugar layer does not attempt a symmetry proof. Four values (solution,
residual, search direction, residual inner product) are carried through
one ``gf_control.repeat`` or ``gf_control.while`` region, so the operator
and optional preconditioner remain ordinary compiler-visible
Tensor/MessagePassing programs.
Pass ``iterations=k`` for a fixed ``gf_control.repeat`` or pass both
``tolerance=eps`` and ``max_iterations=k`` for a device-side bounded
``gf_control.while``. The convergence condition is the absolute Euclidean
residual norm and never synchronizes a scalar through Python.
"""
matvec, extent = _bind_operator(
operator, graph=graph, field=field, src=src, dst=dst,
edge=edge, params=params)
if not isinstance(rhs, tg.Tensor) or rhs.ndim != 1:
raise TypeError("cg rhs must be a rank-one tiga.Tensor")
if rhs.dtype.kind != "float":
raise TypeError("cg currently requires a real floating Tensor")
if extent is not None and rhs.shape != (extent,):
raise ValueError(f"cg rhs shape must be {(extent,)}, got {rhs.shape}")
fixed = iterations is not None
convergent = tolerance is not None or max_iterations is not None
if fixed == convergent:
raise ValueError(
"cg requires exactly one stopping contract: iterations, or "
"tolerance with max_iterations")
if fixed:
if not isinstance(iterations, int) or isinstance(iterations, bool):
raise TypeError("cg iterations must be an integer")
if iterations < 0:
raise ValueError("cg iterations must be non-negative")
else:
if tolerance is None or max_iterations is None:
raise ValueError(
"tolerance-driven cg requires tolerance and max_iterations")
if (not isinstance(max_iterations, int) or
isinstance(max_iterations, bool)):
raise TypeError("cg max_iterations must be an integer")
if max_iterations < 0:
raise ValueError("cg max_iterations must be non-negative")
if preconditioner is not None and not callable(preconditioner):
raise TypeError("cg preconditioner must be a Tensor -> Tensor callable")
solution = tg.zeros_like(rhs) if initial is None else initial
if (not isinstance(solution, tg.Tensor) or solution.shape != rhs.shape or
solution.dtype is not rhs.dtype or solution.device != rhs.device):
raise ValueError("cg initial value must match rhs shape, dtype, and device")
def apply_preconditioner(value: tg.Tensor) -> tg.Tensor:
if preconditioner is None:
return value
output = preconditioner(value)
if (not isinstance(output, tg.Tensor) or output.shape != value.shape or
output.dtype is not value.dtype or output.device != value.device):
raise ValueError(
"cg preconditioner must preserve residual shape, dtype, and device")
return output
residual = rhs - _checked_apply(matvec, solution, extent)
preconditioned = apply_preconditioner(residual)
residual_product = dot(residual, preconditioned)
def checked(value: tg.Tensor) -> tg.Tensor:
return _checked_apply(matvec, value, extent)
if fixed:
assert iterations is not None
return _cg_fixed_loop(
checked, apply_preconditioner, solution, residual, preconditioned,
residual_product, iterations)
assert tolerance is not None and max_iterations is not None
if isinstance(tolerance, tg.Tensor):
threshold = tolerance
if (threshold.shape != () or threshold.dtype is not rhs.dtype or
threshold.device != rhs.device):
raise ValueError(
"cg Tensor tolerance must be scalar and match rhs dtype/device")
else:
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
raise TypeError("cg tolerance must be a scalar Tensor or real number")
if tolerance < 0:
raise ValueError("cg tolerance must be non-negative")
threshold = tg.tensor(tolerance, dtype=rhs.dtype, device=rhs.device)
threshold_squared = threshold * threshold
return _cg_tolerance_loop(
checked, apply_preconditioner, solution, residual, preconditioned,
residual_product, threshold_squared, max_iterations)
def bicgstab(
operator,
rhs: tg.Tensor,
*,
graph: tg.Graph | None = None,
field: str | None = None,
src: Mapping[str, tg.Tensor] | None = None,
dst: Mapping[str, tg.Tensor] | None = None,
edge: Mapping[str, tg.Tensor] | None = None,
params: Mapping[str, object] | None = None,
iterations: int | None = None,
tolerance: tg.Tensor | float | None = None,
max_iterations: int | None = None,
initial: tg.Tensor | None = None,
) -> tg.Tensor:
"""Capture bounded matrix-free BiCGStab for nonsymmetric operators.
Same stopping contracts as ``cg``: ``iterations=k`` for a fixed
``gf_control.repeat``, or ``tolerance=eps`` plus ``max_iterations=k`` for
a bounded device-side ``gf_control.while``. Seven values (solution,
residual, search direction, operator images, and the scalar recurrences)
are carried through one control region.
The convergence test runs at the top of each iteration, as in ``cg``.
Landing exactly on the solution at a half step (residual surrogate
``s = 0``) divides by ``dot(A·s, A·s)`` — the same exact-breakdown hazard
fixed-count CG has; a nonzero tolerance exits before it in practice.
"""
matvec, extent = _bind_operator(
operator, graph=graph, field=field, src=src, dst=dst,
edge=edge, params=params)
if not isinstance(rhs, tg.Tensor) or rhs.ndim != 1:
raise TypeError("bicgstab rhs must be a rank-one tiga.Tensor")
if rhs.dtype.kind != "float":
raise TypeError("bicgstab currently requires a real floating Tensor")
if extent is not None and rhs.shape != (extent,):
raise ValueError(f"bicgstab rhs shape must be {(extent,)}, got {rhs.shape}")
fixed = iterations is not None
convergent = tolerance is not None or max_iterations is not None
if fixed == convergent:
raise ValueError(
"bicgstab requires exactly one stopping contract: iterations, or "
"tolerance with max_iterations")
if fixed:
if not isinstance(iterations, int) or isinstance(iterations, bool):
raise TypeError("bicgstab iterations must be an integer")
if iterations < 0:
raise ValueError("bicgstab iterations must be non-negative")
else:
if tolerance is None or max_iterations is None:
raise ValueError(
"tolerance-driven bicgstab requires tolerance and max_iterations")
if (not isinstance(max_iterations, int) or
isinstance(max_iterations, bool)):
raise TypeError("bicgstab max_iterations must be an integer")
if max_iterations < 0:
raise ValueError("bicgstab max_iterations must be non-negative")
solution = tg.zeros_like(rhs) if initial is None else initial
if (not isinstance(solution, tg.Tensor) or solution.shape != rhs.shape or
solution.dtype is not rhs.dtype or solution.device != rhs.device):
raise ValueError(
"bicgstab initial value must match rhs shape, dtype, and device")
residual = rhs - _checked_apply(matvec, solution, extent)
shadow = residual # r̂ = r₀ stays constant
direction = tg.zeros_like(rhs)
velocity = tg.zeros_like(rhs)
one = tg.tensor(1.0, dtype=rhs.dtype, device=rhs.device)
rho = alpha = omega = one
def checked(value: tg.Tensor) -> tg.Tensor:
return _checked_apply(matvec, value, extent)
if fixed:
assert iterations is not None
return _bicgstab_fixed_loop(
checked, solution, residual, shadow, direction, velocity,
rho, alpha, omega, iterations)
assert tolerance is not None and max_iterations is not None
if isinstance(tolerance, tg.Tensor):
threshold = tolerance
if (threshold.shape != () or threshold.dtype is not rhs.dtype or
threshold.device != rhs.device):
raise ValueError(
"bicgstab Tensor tolerance must be scalar and match rhs")
else:
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
raise TypeError(
"bicgstab tolerance must be a scalar Tensor or real number")
if tolerance < 0:
raise ValueError("bicgstab tolerance must be non-negative")
threshold = tg.tensor(tolerance, dtype=rhs.dtype, device=rhs.device)
threshold_squared = threshold * threshold
return _bicgstab_tolerance_loop(
checked, solution, residual, shadow, direction, velocity,
rho, alpha, omega, threshold_squared, max_iterations)
def linear_solve(
operator,
rhs: tg.Tensor,
*,
method: str = "cg",
graph: tg.Graph | None = None,
field: str | None = None,
src: Mapping[str, tg.Tensor] | None = None,
dst: Mapping[str, tg.Tensor] | None = None,
edge: Mapping[str, tg.Tensor] | None = None,
params: Mapping[str, object] | None = None,
iterations: int | None = None,
tolerance: tg.Tensor | float | None = None,
max_iterations: int | None = None,
initial: tg.Tensor | None = None,
preconditioner: Preconditioner | None = None,
relaxation: tg.Tensor | float = 1.0,
) -> tg.Tensor:
"""Solve ``A·x = b`` matrix-free; ``method`` selects the iteration.
- ``"cg"`` (default) — conjugate gradient; operator contract is symmetric
positive-definite, optional ``preconditioner``.
- ``"bicgstab"`` — BiCGStab for nonsymmetric operators.
- ``"richardson"`` — fixed-count damped iteration; requires
``iterations=k`` and accepts ``relaxation``.
All methods share the stopping contracts: ``iterations=k`` for a fixed
``gf_control.repeat``, or ``tolerance=eps`` with ``max_iterations=k`` for
a bounded device-side ``gf_control.while``.
"""
if method == "cg":
return cg(
operator, rhs, graph=graph, field=field, src=src, dst=dst,
edge=edge, params=params, iterations=iterations,
tolerance=tolerance, max_iterations=max_iterations,
initial=initial, preconditioner=preconditioner)
if method == "bicgstab":
if preconditioner is not None:
raise ValueError(
"linear_solve method='bicgstab' takes no preconditioner in "
"this sugar layer")
return bicgstab(
operator, rhs, graph=graph, field=field, src=src, dst=dst,
edge=edge, params=params, iterations=iterations,
tolerance=tolerance, max_iterations=max_iterations,
initial=initial)
if method == "richardson":
if tolerance is not None or max_iterations is not None:
raise ValueError(
"linear_solve method='richardson' is fixed-count: pass "
"iterations, not tolerance")
if preconditioner is not None:
raise ValueError(
"linear_solve method='richardson' takes no preconditioner")
return richardson(
operator, rhs, graph=graph, field=field, src=src, dst=dst,
edge=edge, params=params, iterations=iterations,
relaxation=relaxation, initial=initial)
raise ValueError(
f"unknown linear_solve method {method!r}: "
"choose 'cg', 'bicgstab' or 'richardson'")
__all__ = [
"Matvec", "Preconditioner", "bicgstab", "cg", "dot", "linear_solve",
"richardson", "vector_norm",
]
Picard 迭代的非线性扩散¶
是什么。 同样的 P1 网格、同样的停止契约——但算子现在是非线性的: 扩散系数依赖于解本身,k(u) = 1 + u²。方程 −∇·(k(u)∇u) = 1 描述热导率 随温度升高的稳态热传导。由于组装形式 F(u) = A(u)·u 随迭代变化, Krylov 方法不再直接适用;最简单的不动点迭代格式是 阻尼 Picard(非线性 Richardson)步,只需要算子像 F(u)——仍然是 一个 MessagePassing kernel,永远不是组装好的矩阵。阻尼 ω 必须让 F 的 Jacobi 矩阵满足 ω·λ_max < 2; 这里 λ_max ≈ 4·max(k)/h。
每条边的 conductance k_e 对当前迭代两个端点的 k 取平均,因此 edge UDF
同时读取 src.u 和 dst.u;两个边界单元(端点处 δ_i = 1,内部为 0)
通过 node UDF 贡献一个对角项,消掉了纯差分 stencil 否则会有的常数零空间。
一个 nonlinear_solve(operator, b, omega=…) 驱动两种契约:
iterations=k 捕获固定次数、可微分的 gf_control.repeat,
tolerance=eps 捕获有界的设备端 gf_control.while——无论哪种都是
单个平坦循环,因为控制区域不支持嵌套。load_gradient() 沿展开的
repeat 对 sum(u) 关于载荷求导;迭代次数保持在个位数,因为展开 VJP
的编译时间随迭代数增长很快,测试用同一个映射的有限差分对梯度做了
交叉验证。
class NonlinearDiffusionApply(tg.MessagePassing):
"""Assemble F(u) = A(u)·u for -∇·(k(u)∇u) with k(u) = 1 + u².
Edge (j -> i) sends the conductance-scaled difference
k_e·(u_i - u_j)/h with the endpoint average k_e = 1 + (u_i² + u_j²)/2;
the sum reducer assembles one nonlinear stiffness row per node. The two
boundary elements (u fixed to 0) contribute the diagonal term
(1 + u_i²/2)·u_i/h, added by the node UDF where dst.boundary = 1:
F(u)_i = (1/h)·Σ_j k_e·(u_i - u_j) + (1/h)·δ_i·(1 + u_i²/2)·u_i,
u₀ = u_{N+1} = 0.
"""
reducer = tg.sum()
def edge(self, src, dst, edge, inverse_spacing):
del edge
# src.u, dst.u: (N,) endpoint values of the current iterate;
# the message is one scalar per edge.
conductance = 1.0 + 0.5 * (src.u * src.u + dst.u * dst.u) # k_e at u
return conductance * (dst.u - src.u) * inverse_spacing
def node(self, dst, incoming, inverse_spacing):
# Boundary-element diagonal at the two end nodes (dst.boundary = 1);
# k_b = 1 + u_i²/2 averages k(u_i) with k(0) = 1 at the fixed end.
conductance = 1.0 + 0.5 * dst.u * dst.u
return incoming + dst.boundary * conductance * dst.u * inverse_spacing
@tg.jit
def _picard_fixed_loop(apply, omega, state, rhs, iterations):
for _ in range(iterations):
state = state + omega * (rhs - apply(state))
return state
@tg.jit
def _picard_tolerance_loop(
apply, omega, state, rhs, threshold_squared, max_iterations,
):
residual = rhs - apply(state)
for _ in range(max_iterations):
if dot(residual, residual) <= threshold_squared:
break
state = state + omega * residual
residual = rhs - apply(state)
return state
def nonlinear_solve(
operator,
rhs: tg.Tensor,
*,
omega: float,
iterations: int | None = None,
tolerance: float | None = 1.0e-6,
max_iterations: int = 2_000,
initial: tg.Tensor | None = None,
) -> tg.Tensor:
"""Solve F(u) = rhs by damped Picard (nonlinear Richardson) iteration.
The fixed-point step u ← u + ω·(rhs − F(u)) runs in ONE flat loop:
``iterations=k`` captures a fixed ``gf_control.repeat`` (differentiable
through ``tg.autograd.grad``; the VJP unrolls the loop, so keep k small
when differentiating); ``tolerance=eps`` with ``max_iterations``
captures a bounded device-side ``gf_control.while`` instead (forward-only
— the while form has no VJP yet). The contracts are exclusive.
"""
if not callable(operator):
raise TypeError("operator must be a Tensor -> Tensor callable")
if not isinstance(rhs, tg.Tensor) or rhs.ndim != 1:
raise TypeError(
"nonlinear_solve rhs must be a rank-one tiga.Tensor")
if rhs.dtype.kind != "float":
raise TypeError("nonlinear_solve requires a real floating Tensor")
fixed = iterations is not None
if fixed == (tolerance is not None):
raise ValueError(
"nonlinear_solve requires exactly one stopping contract: "
"iterations, or tolerance with max_iterations")
if fixed:
if not isinstance(iterations, int) or isinstance(iterations, bool):
raise TypeError("nonlinear_solve iterations must be an integer")
if iterations < 0:
raise ValueError("nonlinear_solve iterations must be non-negative")
else:
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
raise TypeError("nonlinear_solve tolerance must be a real number")
if tolerance < 0:
raise ValueError("nonlinear_solve tolerance must be non-negative")
if (not isinstance(max_iterations, int) or
isinstance(max_iterations, bool)):
raise TypeError("nonlinear_solve max_iterations must be an integer")
if max_iterations < 0:
raise ValueError(
"nonlinear_solve max_iterations must be non-negative")
state = tg.zeros_like(rhs) if initial is None else initial
if not isinstance(state, tg.Tensor) or state.shape != rhs.shape:
raise ValueError("nonlinear_solve initial value must match rhs shape")
if state.dtype is not rhs.dtype or state.device != rhs.device:
raise ValueError(
"nonlinear_solve initial value must match rhs dtype and device")
if fixed:
return _picard_fixed_loop(operator, omega, state, rhs, iterations)
threshold = tg.tensor(
tolerance * tolerance, dtype=rhs.dtype, device=rhs.device)
return _picard_tolerance_loop(
operator, omega, state, rhs, threshold, max_iterations)
def nonlinear_operator(interior_nodes: int):
"""P1 stencil grid + kernel for -∇·((1+u²)∇u) on the unit interval."""
if interior_nodes < 1:
raise ValueError("interior_nodes must be positive")
spacing = 1.0 / (interior_nodes + 1)
graph = tg.Graph.stencil((interior_nodes,), ((-1,), (0,), (1,)))
flags = ( # one entry per boundary element
[2.0] if interior_nodes == 1
else [1.0] + [0.0] * (interior_nodes - 2) + [1.0])
boundary = tg.tensor(flags, dtype=tg.float32) # (N,)
return NonlinearDiffusionApply(), graph, spacing, boundary
def solve(
interior_nodes: int = 8,
*,
omega: float = 0.05,
iterations: int | None = None,
tolerance: float | None = 1.0e-6,
max_iterations: int = 2_000,
):
"""Solve F(u) = h·1 for the nonlinear diffusion operator.
Default is a residual-driven ``gf_control.while``; pass ``iterations=k``
for a fixed ``gf_control.repeat`` loop instead. ω must stay below
2/λ_max of the Jacobian — λ_max ≈ 4·max(k)/h here, so ω = 0.05 is
stable for both grid sizes used below.
"""
kernel, graph, spacing, boundary = nonlinear_operator(interior_nodes)
load = tg.tensor(
[spacing] * interior_nodes, dtype=tg.float32, requires_grad=True) # (N,)
def apply(state): # F(u): (N,) -> (N,), conductance from current iterate
return kernel(
graph=graph,
src={"u": state},
dst={"u": state, "boundary": boundary},
inverse_spacing=1.0 / spacing,
)
if iterations is not None: # fixed repeat and bounded while are exclusive
tolerance = None
solution = nonlinear_solve(
apply,
load,
omega=omega,
iterations=iterations,
tolerance=tolerance,
max_iterations=max_iterations,
) # (N,)
residual = load - apply(solution) # (N,)
error = max(abs(value) for value in residual.tolist())
return solution, residual, error
完整源码:examples/nonlinear_solve.py(可直接运行)
"""Matrix-free 1D nonlinear diffusion solve through MessagePassing and Picard."""
from __future__ import annotations
import tiga as tg
from solvers import dot
class NonlinearDiffusionApply(tg.MessagePassing):
"""Assemble F(u) = A(u)·u for -∇·(k(u)∇u) with k(u) = 1 + u².
Edge (j -> i) sends the conductance-scaled difference
k_e·(u_i - u_j)/h with the endpoint average k_e = 1 + (u_i² + u_j²)/2;
the sum reducer assembles one nonlinear stiffness row per node. The two
boundary elements (u fixed to 0) contribute the diagonal term
(1 + u_i²/2)·u_i/h, added by the node UDF where dst.boundary = 1:
F(u)_i = (1/h)·Σ_j k_e·(u_i - u_j) + (1/h)·δ_i·(1 + u_i²/2)·u_i,
u₀ = u_{N+1} = 0.
"""
reducer = tg.sum()
def edge(self, src, dst, edge, inverse_spacing):
del edge
# src.u, dst.u: (N,) endpoint values of the current iterate;
# the message is one scalar per edge.
conductance = 1.0 + 0.5 * (src.u * src.u + dst.u * dst.u) # k_e at u
return conductance * (dst.u - src.u) * inverse_spacing
def node(self, dst, incoming, inverse_spacing):
# Boundary-element diagonal at the two end nodes (dst.boundary = 1);
# k_b = 1 + u_i²/2 averages k(u_i) with k(0) = 1 at the fixed end.
conductance = 1.0 + 0.5 * dst.u * dst.u
return incoming + dst.boundary * conductance * dst.u * inverse_spacing
@tg.jit
def _picard_fixed_loop(apply, omega, state, rhs, iterations):
for _ in range(iterations):
state = state + omega * (rhs - apply(state))
return state
@tg.jit
def _picard_tolerance_loop(
apply, omega, state, rhs, threshold_squared, max_iterations,
):
residual = rhs - apply(state)
for _ in range(max_iterations):
if dot(residual, residual) <= threshold_squared:
break
state = state + omega * residual
residual = rhs - apply(state)
return state
def nonlinear_solve(
operator,
rhs: tg.Tensor,
*,
omega: float,
iterations: int | None = None,
tolerance: float | None = 1.0e-6,
max_iterations: int = 2_000,
initial: tg.Tensor | None = None,
) -> tg.Tensor:
"""Solve F(u) = rhs by damped Picard (nonlinear Richardson) iteration.
The fixed-point step u ← u + ω·(rhs − F(u)) runs in ONE flat loop:
``iterations=k`` captures a fixed ``gf_control.repeat`` (differentiable
through ``tg.autograd.grad``; the VJP unrolls the loop, so keep k small
when differentiating); ``tolerance=eps`` with ``max_iterations``
captures a bounded device-side ``gf_control.while`` instead (forward-only
— the while form has no VJP yet). The contracts are exclusive.
"""
if not callable(operator):
raise TypeError("operator must be a Tensor -> Tensor callable")
if not isinstance(rhs, tg.Tensor) or rhs.ndim != 1:
raise TypeError(
"nonlinear_solve rhs must be a rank-one tiga.Tensor")
if rhs.dtype.kind != "float":
raise TypeError("nonlinear_solve requires a real floating Tensor")
fixed = iterations is not None
if fixed == (tolerance is not None):
raise ValueError(
"nonlinear_solve requires exactly one stopping contract: "
"iterations, or tolerance with max_iterations")
if fixed:
if not isinstance(iterations, int) or isinstance(iterations, bool):
raise TypeError("nonlinear_solve iterations must be an integer")
if iterations < 0:
raise ValueError("nonlinear_solve iterations must be non-negative")
else:
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
raise TypeError("nonlinear_solve tolerance must be a real number")
if tolerance < 0:
raise ValueError("nonlinear_solve tolerance must be non-negative")
if (not isinstance(max_iterations, int) or
isinstance(max_iterations, bool)):
raise TypeError("nonlinear_solve max_iterations must be an integer")
if max_iterations < 0:
raise ValueError(
"nonlinear_solve max_iterations must be non-negative")
state = tg.zeros_like(rhs) if initial is None else initial
if not isinstance(state, tg.Tensor) or state.shape != rhs.shape:
raise ValueError("nonlinear_solve initial value must match rhs shape")
if state.dtype is not rhs.dtype or state.device != rhs.device:
raise ValueError(
"nonlinear_solve initial value must match rhs dtype and device")
if fixed:
return _picard_fixed_loop(operator, omega, state, rhs, iterations)
threshold = tg.tensor(
tolerance * tolerance, dtype=rhs.dtype, device=rhs.device)
return _picard_tolerance_loop(
operator, omega, state, rhs, threshold, max_iterations)
def nonlinear_operator(interior_nodes: int):
"""P1 stencil grid + kernel for -∇·((1+u²)∇u) on the unit interval."""
if interior_nodes < 1:
raise ValueError("interior_nodes must be positive")
spacing = 1.0 / (interior_nodes + 1)
graph = tg.Graph.stencil((interior_nodes,), ((-1,), (0,), (1,)))
flags = ( # one entry per boundary element
[2.0] if interior_nodes == 1
else [1.0] + [0.0] * (interior_nodes - 2) + [1.0])
boundary = tg.tensor(flags, dtype=tg.float32) # (N,)
return NonlinearDiffusionApply(), graph, spacing, boundary
def solve(
interior_nodes: int = 8,
*,
omega: float = 0.05,
iterations: int | None = None,
tolerance: float | None = 1.0e-6,
max_iterations: int = 2_000,
):
"""Solve F(u) = h·1 for the nonlinear diffusion operator.
Default is a residual-driven ``gf_control.while``; pass ``iterations=k``
for a fixed ``gf_control.repeat`` loop instead. ω must stay below
2/λ_max of the Jacobian — λ_max ≈ 4·max(k)/h here, so ω = 0.05 is
stable for both grid sizes used below.
"""
kernel, graph, spacing, boundary = nonlinear_operator(interior_nodes)
load = tg.tensor(
[spacing] * interior_nodes, dtype=tg.float32, requires_grad=True) # (N,)
def apply(state): # F(u): (N,) -> (N,), conductance from current iterate
return kernel(
graph=graph,
src={"u": state},
dst={"u": state, "boundary": boundary},
inverse_spacing=1.0 / spacing,
)
if iterations is not None: # fixed repeat and bounded while are exclusive
tolerance = None
solution = nonlinear_solve(
apply,
load,
omega=omega,
iterations=iterations,
tolerance=tolerance,
max_iterations=max_iterations,
) # (N,)
residual = load - apply(solution) # (N,)
error = max(abs(value) for value in residual.tolist())
return solution, residual, error
def load_gradient(
interior_nodes: int = 4, *, omega: float = 0.05, iterations: int = 3,
):
"""Differentiate sum(u) w.r.t. the load through the fixed Picard repeat.
The fixed-repeat VJP unrolls the loop, so its compile time grows quickly
with the iteration count — keep it in the low single digits (the
forward-only drivers above run hundreds of iterations cheaply).
"""
kernel, graph, spacing, boundary = nonlinear_operator(interior_nodes)
load = tg.tensor(
[spacing] * interior_nodes, dtype=tg.float32, requires_grad=True) # (N,)
def apply(state): # F(u): (N,) -> (N,)
return kernel(
graph=graph,
src={"u": state},
dst={"u": state, "boundary": boundary},
inverse_spacing=1.0 / spacing,
)
solution = nonlinear_solve(
apply, load, omega=omega, iterations=iterations, tolerance=None) # (N,)
return tg.autograd.grad(solution.sum(), load) # (N,)
# Inspect: solve()[0].mlir() (bounded gf_control.while loop),
# solve(iterations=200)[0].mlir() (fixed gf_control.repeat loop)
if __name__ == "__main__":
solve()
load_gradient()
本机编译产物(Ryzen 7 255 · RTX 5070 Ti 实测)
### NonlinearDiffusionApply
backend: cpu:0
provider: tiga-runtime
lowering: gf-tensor-relation-autograd
passes: bind-static-csr-snapshot, capture-message-passing-udf, analyze-reducer-algebra, lower-csr-to-gather-segment, fuse-edge-node-regions
remark: [planning] edge/node UDFs remain in the differentiable Tensor DAG
remark: [planning] gf-tensor-vjp generates CSR gather/segment-sum adjoints
remark: [planning] reducer lowering: builtin-additive-state
variant cache: hits=0, misses=3
### NonlinearDiffusionApply
backend: cpu:0
provider: tiga-runtime
lowering: gf-tensor-relation-autograd
passes: bind-static-csr-snapshot, capture-message-passing-udf, analyze-reducer-algebra, lower-csr-to-gather-segment, fuse-edge-node-regions
remark: [planning] edge/node UDFs remain in the differentiable Tensor DAG
remark: [planning] gf-tensor-vjp generates CSR gather/segment-sum adjoints
remark: [planning] reducer lowering: builtin-additive-state
variant cache: hits=0, misses=1