#CCSP2026E. 大模型推理算子逆向
大模型推理算子逆向
题目来自 CCSP2026 T5,评测使用官方数据。我们承认原始题面、大样例、官方数据版权均归中国计算机学会(CCF)所有,因此题面与评测服务均免费对外开放。如果您认为我们侵犯了您的权益,可联系我们。
本题复原了场上选手在各子任务的最佳性能分。由于判分系统需要,本题实际得分为本题原本得分的 5 倍,仅需正确完成运算即视为通过本题。此外,我们添加了禁用其他
GCC optimize的限制,从而保证评测的确定性和公平性。本题为传统题,仅为了还原场上评测设置配置为交互题,且只允许使用 C++ 进行提交。此外本题运行时间较长,请耐心等待,且不要滥用评测资源~
时间限制: 25.0 秒
空间限制: 512 MB
相关文件: down.zip
题目描述
在一次针对失控超级人工智能 AliceNet 的隔离分析中,科学家从一块损坏的计算节点中恢复出了一段 RISC-V 64 位程序。绝大多数与核心算法有关的符号和文档都已被清除,唯一保留下来的核心函数名是:
extern "C" void very_important_function(
int magic, void *ptr_a, void *ptr_b, void *ptr_c, void *result
);
分析表明,这个函数是 AliceNet 系统中的核心算子,其在 AliceNet 留下的执行记录中被极其频繁地调用。遗憾的是,科学家只恢复出了它的 RISC-V64 汇编代码,并不知道它所实现的数学过程。作为临危受命的科学家,你需要对汇编代码进行逆向分析,使用 C++ 重新实现该算法的数学过程,并基于 RISC-V V 向量扩展(RVV)优化实现,尽可能减少执行该函数所需的指令条数。优化后的代码将被用于开发更先进的 BobNet,以对抗邪恶的 AliceNet。
你的任务
你需要完成一个新版本的 very_important_function 函数实现:
extern "C" void very_important_function(
int magic, void *ptr_a, void *ptr_b, void *ptr_c, void *result
);
并满足如下两个要求:
- 完整还原
very_important_function的数学过程; - 尽可能地对新版本的
very_important_function进行优化,减少调用该函数执行的 RISC-V 指令条数。
你只需要在随题提供的 BobNet.cpp 中实现题目指定的函数,不需要自行实现输入输出或 main 函数。不得修改函数的名称、extern "C" 链接约定和参数类型;四个 void * 参数均指向由评测框架分配的 int 数据区域,可在函数内部转换为相应的 int *。评测框架负责读取数据、分配数组、调用函数、记录退休指令数并检查结果。
ptr_a、ptr_b、ptr_c 指向的输入区域只允许读取,不得修改。四个参数所指向的有效数据区域保证两两不重叠。result 的初始内容不作保证,参赛程序应写入全部所需结果。
允许在 BobNet.cpp 中定义辅助函数,并使用局部、动态分配、全局或静态临时空间,但须遵守内存限制。与本次输入有关的计算和预处理必须在 very_important_function 的调用期间完成;其间执行的辅助函数、初始化和临时空间管理均计入指令数。
各测试点独立评测,不保证全局或静态临时空间的内容在不同测试点之间保留;每次调用均须依据本次输入独立完成计算。
参考资料与工具
以下内容用于帮助你快速上手阅读汇编、遵守函数接口并编写 RVV 代码,更完整的规范和工具手册位于随题资源目录的 documents/ 中。
RISC-V 指令集与 ABI
RISC-V 是一种开放、模块化的精简指令集架构(Instruction Set Architecture,ISA)。ISA 定义软件可见的指令、寄存器及其行为。RISC-V 规范分为两部分:非特权态规范描述通常可由应用程序使用的基础指令和标准扩展;特权态规范描述机器态、监管态、异常、中断以及特权控制状态寄存器等系统行为。本题反汇编的主要计算逻辑属于非特权态指令;如需了解更完整的系统执行环境,也可以查阅特权态规范。
本题使用 RV64GCV:RV64 表示整数寄存器宽度为 64 位,G 表示通用基础扩展集合,C 表示压缩指令扩展,V 表示向量扩展。指令集说明一条指令“做什么”,应用二进制接口(Application Binary Interface,ABI)则约定不同目标文件之间“如何协作”,包括参数传递、寄存器保存、栈布局、数据类型大小和 ELF 目标文件格式。
例如,考虑下面两个简单的 C 函数:
int add(int x, int y) {
return x + y;
}
int add_then_double(int x, int y) {
return add(x, y) * 2;
}
在 RV64 ABI 下,一种用于说明调用约定的等价汇编写法如下。实际编译器可能内联 add,或生成其他语义等价的指令序列。
add:
addw a0, a0, a1
ret
add_then_double:
addi sp, sp, -16
sd ra, 8(sp)
call add
slliw a0, a0, 1
ld ra, 8(sp)
addi sp, sp, 16
ret
调用 add_then_double 时,两个 int 参数依次位于参数寄存器 a0 和 a1 中,返回值也放在 a0 中。addw 和 slliw 执行 32 位整数运算,并将结果符号扩展到 64 位寄存器。call add 会改写返回地址寄存器 ra,因此还要继续返回给上层调用者的 add_then_double 先将原 ra 保存到栈中,并在 ret 前恢复。栈指针 sp 在标准 ABI 下须保持 16 字节对齐,所以示例一次分配 16 字节栈空间。参数寄存器 a0 至 a7 和临时寄存器 t0 至 t6 均由调用者负责在需要时保存;被调用者若使用 s0 至 s11,则必须在返回前恢复其原值。阅读 very_important_function 的反汇编时,应根据这些规则判断寄存器中保存的是参数、返回值、临时值还是跨函数调用仍需保留的值。
更多详细内容请见:
documents/riscv-unprivileged-v20260120.pdf:RISC-V 非特权态 ISA;documents/riscv-privileged-v20260120.pdf:RISC-V 特权态架构;documents/riscv-abi-v1.0.pdf:RISC-V 调用约定和 ELF ABI。
RISC-V V 扩展
RISC-V V 扩展(RVV)在标量寄存器之外提供 v0 至 v31 共 32 个向量寄存器。向量指令把一组数据元素装入向量寄存器,再对本轮的活动元素逐元素执行相同操作。例如,在 SEW=32, LMUL=1, vl=4 时,vadd.vv v3, v1, v2 会分别计算 v1 和 v2 中第 0 至第 3 个元素之和,并写入 v3 对应位置。索引不小于 vl 的尾部元素不会作为本轮活动元素参与运算;其原值是否保留由尾部策略 tu(tail undisturbed)或 ta(tail agnostic)决定。使用掩码时,被屏蔽元素的处理类似地由 mu 或 ma 决定。
RVV 使用以下几个参数描述硬件能力和一次向量操作的形状:
VLEN(vector register length)是一个物理向量寄存器的位数。本题机器的VLEN=128,这是硬件固定参数;ELEN(maximum element width)是硬件向量运算所支持的最大元素位宽。本题机器的ELEN=64,同样是硬件固定参数;SEW(selected element width)是程序为当前向量操作选择的元素位宽,例如 8、16、32 或 64 位。它记录在vtype中,可以在运行时改变。本题主要处理 32 位有符号整数,因此通常选择SEW=32;LMUL(vector register group multiplier)表示一个逻辑向量寄存器组占用的物理向量寄存器数量。LMUL=1使用一个寄存器,LMUL=2、4、8 分别把相邻的 2、4、8 个寄存器组成一组;规范也支持分数LMUL。增大LMUL可以在一条指令中容纳更多元素,但会减少同时可用的独立寄存器组数量;VL(vector length,在汇编和 CSR 名称中写作vl)是本轮向量指令实际处理的活动元素数。它由配置指令根据程序请求和当前SEW、LMUL计算,不是固定常量。
在给定 SEW 和 LMUL 后,一个逻辑向量寄存器组最多容纳的元素数为
例如,在本题机器上,SEW=32, LMUL=1 时 VLMAX=4,而 SEW=32, LMUL=4 时 VLMAX=16。程序把希望在本轮处理的元素数称为 AVL(application vector length),通常就是循环中尚未处理的元素数。vsetvl、vsetvli 或 vsetivli 先根据 SEW 和 LMUL 确定 VLMAX,再按如下规则设置实际的 vl:
- 若 ,则 ;
- 若 ,则 $\lceil\mathrm{AVL}/2\rceil\le\mathrm{vl}\le\mathrm{VLMAX}$,具体取值由实现决定;
- 若 ,则 。
因此,不能笼统地认为 vl 永远等于 min(AVL, VLMAX)。程序应读取配置指令返回的实际 vl,并用它更新指针和剩余元素数。对于相同的 AVL 和 VLMAX,同一硬件实现给出的结果是确定的。
以 VLMAX=16 为例:AVL=10 时 vl 必须为 10;AVL=40 时 vl 必须为 16;AVL=20 时 vl 可以是 10 至 16 中由当前实现选定的某个值。
vsetvli 的基本形式是 vsetvli rd, rs1, SEW, LMUL, tail, mask:rs1 中是 AVL,rd 接收计算得到的 vl,后四项写入 vtype。下面几条指令展示了如何配置 SEW 和 LMUL:
# a0 保存 AVL,t0 接收实际 vl
vsetvli t0, a0, e32, m1, ta, ma # SEW=32, LMUL=1,VLMAX=4
vsetvli t0, a0, e32, m4, ta, ma # SEW=32, LMUL=4,VLMAX=16
vsetvli t0, a0, e16, m2, tu, mu # SEW=16, LMUL=2,VLMAX=16
其中 e32、e16 分别选择 32 位、16 位的 SEW,m1、m2、m4 分别选择 1、2、4 倍的 LMUL。ta, ma 表示不要求保留尾部和被屏蔽元素,tu, mu 表示保留它们。配置会持续影响后续向量指令,直到另一条向量配置指令修改 vtype 或 vl。
RVV 通常使用 strip-mining 循环处理任意长度的数据。下面的示例对两个 INT32 数组逐元素相加,只用于说明循环结构,不代表本题算法:
# a0、a1:两个源数组;a2:目标数组;a3:剩余元素数
beqz a3, .Ldone
.Lloop:
vsetvli t0, a3, e32, m4, ta, ma
vle32.v v4, (a0)
vle32.v v8, (a1)
vadd.vv v12, v4, v8
vse32.v v12, (a2)
slli t1, t0, 2
add a0, a0, t1
add a1, a1, t1
add a2, a2, t1
sub a3, a3, t0
bnez a3, .Lloop
.Ldone:
循环每轮把剩余元素数作为 AVL,再按返回的 vl 处理数据、移动字节指针并扣减元素数,所以能够自然覆盖不足一个完整向量组的尾部。即使已知本题的 VLEN,也不能假设 或待处理数据量一定能被 VLMAX 整除。
更多详细内容请见 documents/riscv-v-spec-v1.0.pdf。
RISC-V V Intrinsics
RISC-V V Intrinsics 是供 C/C++ 程序调用 RVV 能力的一组编译器接口。使用时包含 <riscv_vector.h>,通过向量类型和 intrinsic 函数表达加载、计算、掩码及存储操作;编译器负责选择相应指令、分配向量寄存器并安排必要的 vsetvl。下面的代码只演示长度无关的循环结构,不代表本题算法:
#include <cstddef>
#include <riscv_vector.h>
for (std::size_t i = 0; i < count;) {
std::size_t vl = __riscv_vsetvl_e32m1(count - i);
vint32m1_t value = __riscv_vle32_v_i32m1(source + i, vl);
__riscv_vse32_v_i32m1(destination + i, value, vl);
i += vl;
}
类型名中的 i32m1 表示 32 位有符号整数元素和 LMUL=1。Intrinsic 表达的是向量语义,编译器仍可能合并、移动或删除操作,因此最终是否产生了预期指令应以反汇编结果为准。详细内容以 documents/riscv-vector-c-intrinsics-v1.0.pdf 为准。
使用 objdump 查看目标文件
进入源码目录并加载随题工具链:
cd llm-operator/src
export TOOLCHAIN_ROOT=../rvv-toolchain
直接反汇编需要分析的函数:
"$TOOLCHAIN_ROOT/bin/riscv64-unknown-elf-objdump" -d -C \
--disassemble=very_important_function AliceNet.elf | less
完整选项说明见 documents/binutils-2.47.pdf。
实现约束
目标平台中 int 为 32 位有符号整数。原函数的输入、单项运算、中间结果和最终结果均采用 INT32 语义。
测试数据保证原算法执行过程中所有单项乘积、任意顺序的部分和、中间值及最终值都处于 INT32 可表示范围内。因此,正确实现不需要也不能依赖有符号整数溢出。
输入格式
评测驱动读取一组整数,目前仅知道读入的第一个数字为具有特殊含义的魔法数(magic number),其余数字的含义未知,需要通过逆向研究汇编代码破解。
实际数据中的所有数字位于同一行,以空格分隔。参赛者无需自行读取这些数据。
输出格式
very_important_function 返回后,其结果将被记录在 result 所指向的内存空间中。其具体的数据组织形式未知。
测试程序的输出包含一系列整数,其中第一个数为执行 very_important_function 所实际使用的 RISC-V 指令条数 instret,剩下的数字为 result 所指向的内存空间中所包含的结果数据。需要注意的是,instret 由评测驱动单独记录并输出,不是 result 的一部分。参赛者只需将计算结果写入 result 指向的内存,不得自行读取指令计数器,也不得向标准输出或标准错误输出任何内容。
3 6 6 4 -7 -10 5 10 -1 6 -6 3 2 -9 1 -1 -2 6 7 0 6 2 -5 6 9 8 -2 5
1500 104 468 260 288 178 80 32 144 80
样例 1 解释
样例的第一个整数 3 是传给 的值。其后共有 27 个整数,评测驱动按输入顺序将它们划分为三个连续的数据区域,每个区域包含 9 个整数,并将三个区域的首地址依次通过 ptr_a、ptr_b、ptr_c 传入函数。为便于观察,这三个区域分别是:
ptr_a: 6 6 4 -7 -10 5 10 -1 6
ptr_b: -6 3 2 -9 1 -1 -2 6 7
ptr_c: 0 6 2 -5 6 9 8 -2 5
上述分组只说明评测驱动如何组织样例输入以及各指针指向何处。、三个输入区域和 result 之间的具体数学关系仍需通过分析 very_important_function 的汇编代码得出。
输出内容是对应 .ans 文件中的标准答案,不是 BobNet.elf 某次运行必须原样输出的内容。第一个整数 1500 是该样例的指令数上限,不是 result 的内容;程序实际输出的 instret 必须严格小于该值。其余 9 个整数依次是 result 所指向内存中的预期数据,必须与程序输出逐项相同。
指令计数
评测使用 QEMU 插件统计两个标记地址之间动态执行的 Guest 指令数,并将该数值作为 instret 输出。计数边界恰好覆盖 very_important_function 的函数体,包括函数内部的全部计算、数据变换、临时空间管理、辅助函数调用以及其他内部处理,不包括调用包装器中的标记指令。
评测框架执行的输入读取、测试数据分配、函数调用前的准备、函数返回后的正确性检查和结果输出不计入该函数的指令数。只有满足下文“评分方式”中计分资格要求的测试组,才会计算平均指令数并参与性能评分。
正式计数使用随题提供的固定 RISC-V 环境。参赛者在其他机器上测得的运行时间、宿主机硬件计数器或编译器估算值不作为评分依据。
目标环境
参赛程序按 RV64GCV 架构和 LP64D ABI 编译。本地测试脚本使用 QEMU user-mode,并固定传入 -cpu rv64,v=true,vext_spec=v1.0,vlen=128,elen=64。因此,标量整数寄存器宽度 XLEN 为 64 位,每个向量寄存器的位宽 VLEN 为 128 位,向量单个元素所支持的最大位宽 ELEN 为 64 位,RISC-V V 扩展版本为 1.0。该核心算子的数值数据为 32 位有符号整数,因此主要向量计算使用 SEW=32。这些参数决定一条向量指令每轮最多能处理多少个元素,但并不要求输入规模恰好是该数量的整数倍。
- 指令集:RV64GCV;
- ABI:LP64D;
- RISC-V V 扩展版本:RVV 1.0;
XLEN = 64, VLEN = 128, ELEN = 64;- 主要 INT32 向量计算采用
SEW = 32,LMUL和每轮vl由参赛实现按需设置; - 编译器与编译参数:以随题
Makefile为准。
程序必须在上述环境中独立完成计算,不得访问网络、额外输入文件或未允许的外部服务。
本地编译与测试
先进入源码目录并设置随题工具链路径:
cd llm-operator/src
export TOOLCHAIN_ROOT=../rvv-toolchain
修改 BobNet.cpp 后重新编译:
make
该命令生成当前目录下的 BobNet.elf。
测试单个样例
python3 run_with_counter.py \
--input_file ../samples/1-1.in \
--answer_file ../samples/1-1.ans
测试通过时输出 ok (instructions: <实际指令数>);测试失败时输出错误信息。需要保存被测程序的原始输出时,可追加 --save_output ./output.txt。不指定 --exec 时,脚本默认测试当前目录下的 ./BobNet.elf。
批量测试
python3 run_with_counter.py --batch_dir ../samples
脚本按文件名字典序运行目录中的所有 .in 文件,并使用同名 .ans 文件作为标准答案。每个样例输出一行 Running <测试名> ...ok (instructions: <实际指令数>) 或 Running <测试名> ...error,最后输出 Total <通过数>/<总数>。--batch_dir 不得与 --input_file、--answer_file 或 --save_output 同时使用。
评分方式
本题使用隐藏测试集,一个测试组即一个子任务,各测试组独立计分。评分依次确定计分资格、计算平均指令数,再按公式计算得分。
计分资格
对于某份提交,只有当测试组 内全部测试点均正常完成、满足时间及内存限制、计算结果完全正确,且各测试点的实际指令数均严格小于对应答案文件规定的上限时,该提交才获得该组的计分资格。
任一测试点结果错误、运行异常、超时、超出内存限制,或实际指令数达到或超过上限,该组得分均为 0,且不参与该组最优值的更新。其他测试组独立评定,不受该组失败的影响。
平均指令数与排行榜最优值
单个测试点 的评价指标是执行 very_important_function 所使用的 RISC-V 指令条数,记为 ,其值越小越好。对于获得计分资格的测试组 ,取该组全部测试点实际指令数的算术平均值:
其中 为该组测试点数,本题每组均为 4。每个测试点均须单独满足指令数上限要求,不能以平均值代替逐点判定。
记 为截至当前、所有获得该组计分资格的提交中 的最小值,包括刚完成评测的提交。某份提交在其他组失败,不影响其参与本组最优值的统计;不得将不同提交在各测试点上的最小指令数拼接为该组最优值。
尚无合格提交时,该组暂无最优值,所有提交在该组均得 0 分。首份获得该组计分资格的提交建立该组最优值,并在此时获得该组满分。本题不另设官方初始基准,答案文件中的指令数上限仅用于判定计分资格。
得分公式
对于获得计分资格的测试组 ,设该组满分为 ,则该提交在该组的得分为:
$$\mathrm{score}_G=W_G\times\left(0.2+0.8\times\frac{I_{\mathrm{best},G}}{\max\left(I_G,I_{\mathrm{best},G}\right)}\right).$$公式中的 基础分仅适用于获得该组计分资格的提交;未获得资格时,该组得分为 0,不适用上述公式。
每份提交完成评测后,先更新各组的 ,再计算得分。若某组出现新的最小值,所有已完成评测且获得该组计分资格的提交在该组的得分均按新值重新计算;未获得该组计分资格的提交仍得 0 分。
各测试组满分 之和为 100。一份提交的总分为该提交各测试组得分之和,各组计算过程中不提前舍入,总分四舍五入保留六位小数。
子任务
对于所有测试数据:
- ;
- 所有合法输入均满足前述 INT32 范围保证;
本题共有 10 个子任务,每个子任务包含 4 个正式测试样例,共计 40 个。本题满分为 100 分,每组子任务分值为 10 分。
| 子任务编号 | |
|---|---|
| 1 | 3 |
| 2 | 32 |
| 3 | 129 |
| 4 | 500 |
| 5 | 1000 |
| 6 | 500 |
| 7 | |
| 8 | |
| 9 | |
| 10 |