cvxcore:CVXPY 凸优化问题规范化(Canonicalization)的 C++ 内核详解
cvxcore:CVXPY 凸优化问题规范化(Canonicalization)的 C++ 内核详解
导读
本文以 CVXPY 仓库中 cvxcore 的历史说明文档 为主体,系统讲解 cvxcore 的定位、工作原理、代码组织以及与 CVXPY 等建模语言的集成方式。读完本文,你将理解"凸优化建模语言 → 线性算子树(LinOp tree)→ 稀疏问题数据矩阵"这条核心链路是如何由 C++ 内核加速完成的,并掌握在 CVXPY 中通过 get_problem_matrix 获取 COO 格式问题矩阵、为其他 CVX.* 求解器接入 cvxcore 的完整步骤。
1. cvxcore 是什么:建模语言与求解器之间的公共规范化层
CVX、CVXPY、Convex.jl 等凸优化建模工具,工作方式如出一辙:先把用户书写的高层问题描述翻译成低层、规范的形式(canonical form),再交给后端求解器处理。这个"翻译"过程称为规范化(canonicalization),包括将目标函数与约束展开为线性算子树、把算子树组装为系数矩阵、附加常数列等步骤。
cvxcore 正是把这类建模系统共有的规范化操作抽离出来,打包成一个带简洁 C++ 接口的独立软件包:
- 消除了在不同语言中重复实现规范化流程的需要;
- 相比高层语言实现能带来显著的性能提升——原文档给出的历史实测结论是"大多数问题上相对最初版 CVXPY 实现有 3–10 倍加速"(该数字为 OLD_README 记录的历史性能结论,非当前版本基准)。
从当前仓库的 README.md 可以确认 cvxcore 的历史定位:它最初是为 CVX、CVXPY、Convex.jl 等多个建模语言设计的 C++ 后端,最终只被 CVXPY 采用;当前它被官方标记为 deprecated,但仍作为 CVXPY 默认的规范化后端在持续服役,因此理解它的架构对阅读 CVXPY 源码仍然很有价值。
2. 与 CVXPY 的集成:从 LinOp 树到稀疏问题矩阵
在 CVXPY 中,规范化链路可以概括为三个阶段:
- 构造线性算子树:目标函数与每条约束都被表示为一棵 Python 层的
LinOp树(叶子是变量、参数、常数,内部节点是乘法、转置、切片、堆叠等仿射算子); - 树 → C++ LinOp 树:通过树遍历把 Python 的 LinOp 树转换为 C++ 的
LinOp树; - C++ 建矩阵:把 C++ LinOp 树向量交给 cvxcore 的
build_matrix,返回包含稀疏问题数据的ProblemData结构,再转换回 Python 可用的 NumPy/SciPy 数组。
当前 CVXPY 侧的执行入口是 canonInterface.py 中的 get_problem_matrix:
def get_problem_matrix(linOps,
var_length,
id_to_col,
param_to_size,
param_to_col,
constr_length,
canon_backend=None):
其参数含义如下:
| 参数 | 含义 |
|---|---|
linOps |
Python linOp 树列表,表示一个仿射表达式(对应约束/目标) |
var_length |
问题变量的总长度 |
id_to_col |
变量 id 到列偏移量的映射 |
param_to_size |
参数 id 到参数尺寸的映射 |
param_to_col |
参数 id 到张量中列的映射 |
constr_length |
所有约束尺寸之和 |
canon_backend |
规范化后端:'CPP'(默认)、'SCIPY'、'RUST'、'COO',可影响编译时间 |
get_problem_matrix 返回一个形状为 constr_length * (var_length + 1) 行、param_size + 1 列的稀疏 CSC 矩阵(其中 +1 列对应常数列偏移)。后端选择可通过 CVXPY_DEFAULT_CANON_BACKEND 环境变量全局切换(见 get_default_canon_backend)。
当选用 'CPP' 后端时,实际调用的是 cppbackend.py 中的 build_matrix:它构造 SWIG 生成的 cvxcore Python 绑定对象(cvxcore.ConstLinOpVector、cvxcore.IntIntMap),逐一调用 build_lin_op_tree 把 Python LinOp 树转换到 C++ 侧,再调用 cvxcore.build_matrix(lin_vec, var_length, id_to_col_C, param_to_size_C, num_threads) 获得 ProblemData,最后通过 getV/getI/getJ 取出三元组并拼装成 SciPy 的 csc_array。
3. 为其他 CVX.* 求解器接入 cvxcore:三步走
OLD_README 为希望在自己语言/求解器中复用 cvxcore 的开发者给出了明确的三步集成方案:
第 1 步:把目标与约束表示为线性算子树
在求解过程的某个节点,问题的目标与每条约束都必须是线性算子树(linear atom tree / LinOp tree)。将高层语言的 LinOp 树转换为 C++ LinOp 树时,需要做树遍历,并针对稠密矩阵与稀疏矩阵的不同表示处理若干特殊情形。
参考实现是 canonInterface.py 中的 build_lin_op_tree。当前仓库中 cppbackend.py 的实现是:先用一个栈做后序遍历(BFS 收集、逆序建树),通过 make_linC_from_linPy 把每个 Python LinOp 映射为 C++ cvxcore.LinOp,并用 linPy_to_linC 字典做记忆化,保证同一节点只构建一次;数值数据通过 set_linC_data 区分三种情况写入——切片(slice)、标量(走 set_dense_data)与矩阵(set_matrix_data,稀疏矩阵走 set_sparse_data)。
第 2 步:调用 build_matrix 获得稀疏问题数据
把 C++ LinOp 向量传入 cvxcore 的矩阵构建函数,返回一个 ProblemData 结构,其中包含问题的稀疏矩阵表示。目前问题数据以 COO 表示存放在 std::vector 中:
- Python:可借助 cvxcore 的
getV/getI/getJ函数高效地拆包为 NumPy 数组; - 其他语言:需要自行编写高效拆包逻辑(
getV/getI/getJ在 ProblemData.hpp 中实现,通过 SWIG 的 numpy.i typemap 暴露为返回连续一维数组的接口)。
第 3 步:在目标语言中复刻 canonInterface.py
完成前两步后,你实际上已经在目标语言里复刻出了 canonInterface.py,可以执行如下形式的核心调用:
V, I, J, b = canonInterface.get_problem_matrix(lin_expr_tree, var_offset_map)
其中 V, I, J 是问题矩阵 A 的 COO 表示,b 是常数向量。V, I, J 与 b 随后可直接按需传给你的求解器。
4. 源码级原理:从 LinOp 枚举到系数矩阵组装
4.1 算子类型枚举:26 种 LinOp
LinOp.hpp 定义了 operatortype 枚举,覆盖 CVXPY 规范化所需的全部仿射算子:
VARIABLE, PARAM, PROMOTE, MUL, RMUL, MUL_ELEM, DIV, SUM, NEG,
INDEX, TRANSPOSE, SUM_ENTRIES, TRACE, RESHAPE, DIAG_VEC, DIAG_MAT,
UPPER_TRI, CONV, HSTACK, VSTACK, SCALAR_CONST, DENSE_CONST,
SPARSE_CONST, NO_OP, KRON, KRON_R, KRON_L
LinOp 类的数据字段由算子类型决定(不执行字段合法性检查):shape_ 记录输出形状,args_ 保存子节点,slice_ 保存切片数据((start, end, step) 三元组),数值数据通过 set_dense_data(要求 Fortran 序连续 double 数组,内部用 Eigen::Map 映射为 Eigen::MatrixXd)或 set_sparse_data(从 COO 三元组构造 Eigen::SparseMatrix)注入。注意这两个方法的函数签名必须与 SWIG 接口文件中的 typemap 严格一致才能正常编译运行。
4.2 每个算子的系数提取:LinOpOperations
LinOpOperations.cpp 定义了与每个 LinOp 一一对应的系数提取函数(对应 18 个特殊情形的历史说法,实际当前枚举已扩展至 26 种算子,函数包括 get_variable_coeffs、get_const_coeffs、get_param_coeffs、get_mul_mat、get_transpose_mat、get_index_mat、get_reshape_mat、get_hstack_mat、get_kronr_mat、get_kronl_mat 等)。核心分发函数 get_node_coeffs(lin, arg_idx) 是一个大型 switch 语句,按 LinOp 类型路由到对应的系数提取函数,最终汇总成 Tensor——一个嵌套映射结构:参数 id → (变量 id → 系数矩阵列表)(见 Utils.hpp 的 typedef std::map<int, std::map<int, std::vector<Matrix>>> Tensor)。
4.3 矩阵组装与 OpenMP 并行
cvxcore.cpp 是矩阵构建算法的核心:
build_matrix(constraints, var_length, id_to_col, param_to_size, num_threads):逐约束处理,按约束形状的向量长度乘积(vecprod)计算垂直偏移;process_constraint:对每个约束调用lin_to_tensor提取系数,把变量 id 通过id_to_col换算成水平列偏移(常量走CONSTANT_ID = -1,其水平偏移即var_length,落在最后一列);add_matrix_to_vectors:用 Eigen 稀疏矩阵迭代器把系数块追加进 V/I/J 三元组向量;- 编译时若开启
_OPENMP,约束循环使用#pragma omp parallel for并行处理,线程数由 CVXPY 的s.get_num_threads()传入。
4.4 底层数据结构一览
| 类型 | 定义 | 说明 |
|---|---|---|
Matrix |
Eigen::SparseMatrix<double> |
cvxcore 的稀疏矩阵数据类型(当前 README 说明其存储可为广义 CSC/CSR,makeCompressed() 可转为标准格式) |
Tensor |
map<int, map<int, vector<Matrix>>> |
参数→(变量→系数矩阵块) 的三层嵌套 |
ProblemData |
见 ProblemData.hpp | 含 TensorV/TensorI/TensorJ 三个 map,按参数 id 与参数向量位置存放 COO 三元组 |
CONSTANT_ID |
-1 |
常数的统一伪 id(见 Utils.hpp) |
5. SWIG 绑定与构建流程
cvxcore.i 是 SWIG 接口文件,负责把 cvxcore 暴露给多种常见编程语言(cvxcore.i):
%include "numpy.i"+import_array()启用 NumPy C API;- 为
set_dense_data应用double* IN_FARRAY2typemap(要求 Fortran 序二维数组); - 为
set_sparse_data应用INPLACE_ARRAY1typemap; - 为
getV/getI/getJ应用ARGOUT_ARRAY1typemap,使其在 Python 中返回连续一维 NumPy 数组; - 实例化
IntVector、IntIntMap、ConstLinOpVector等std容器模板; - 声明
build_matrix的两个重载入口(带num_threads与带constr_offsets)。
生成的 Python 绑定是 cvxcore.py(SWIG 自动生成)。对 cvxcore 做修改后,当前仓库 README.md 记录了可见化流程:在 cvxpy/cvxcore 目录下运行 swig -Isrc -c++ -python python/cvxcore.i 重新生成绑定,再回到仓库根目录执行 pip install -e . 重新编译;或直接用仓库根目录的 bash rebuild_cvxcore.sh 一步完成。
6. 代码组织速览
| 目录/文件 | 内容 |
|---|---|
| cvxpy/cvxcore/src/ | cvxcore 的 C++ 源码:cvxcore.(c/h)pp 实现矩阵构建算法与 build_matrix 主入口;LinOp.hpp 定义 LinOp 类;LinOpOperations.(c/h)pp 实现各算子的系数提取;ProblemData.hpp 定义 build_matrix 的返回结构;Utils.hpp 定义 Matrix/Tensor 等基础类型 |
| cvxpy/cvxcore/python/ | 与 CVXPY 集成相关的 Python 代码:canonInterface.py 手工编写,负责 Python↔C++ LinOp 树转换与 get_problem_matrix;cppbackend.py 封装 SWIG 绑定调用;cvxcore.py 为 SWIG 自动生成的绑定 |
| cvxpy/cvxcore/python/cvxcore.i | SWIG 接口文件,向多种语言暴露函数与数据类型 |
| tests | 历史文档提及 test_linops.py(验证 LinOp 构建与表示正确性)与 huge_testman.py(在 EE364A 系列问题上做基准测试);这两个文件未保留在当前仓库树中,相关回归测试已由 cvxpy/tests/ 下的测试体系承接 |
7. 现状、限制与联系
- 现状:cvxcore 目前仅服务于 CVXPY,官方在 README.md 中明确标注其为 deprecated,未来可能在不通知的情况下发生破坏 API 的变更;但作为 CVXPY 默认
'CPP'规范化后端,其算法与数据结构仍是理解 CVXPY 矩阵规范化链路的钥匙。 - 适用前提:
'CPP'后端依赖编译安装的cvxcorePython 模块;若未安装,cppbackend.py会抛出ModuleNotFoundError提示改用其他后端。 - 联系:原文档署名联系邮箱为
{piq93,jackzhu,millerjp}@stanford.edu,对应项目早期(Stanford 团队)的贡献者;如需反馈问题,更合适的渠道是当前仓库的 issue 与 CONTRIBUTING.md 中说明的贡献流程。