P5338. 整数缩放因子下的全整型矩阵乘积
场次:2026年9月2日 · 来源:CodeFun2000 P5338
题目描述
在模型量化中,常需要把高精度浮点数转换为低位整数。对于实数 与缩放因子 ,线性量化定义为 ,反量化则恢复为 。在 比特量化下,缩放因子通常取 。矩阵可以按行或按列分别量化,并为每一行或每一列保存一个缩放因子。
为了进一步避免缩放因子参与浮点运算,可以把缩放因子 写成两个整数 的比值:。这样,两个缩放因子相乘时可以表示为
本题要求在这种全整数缩放因子表示下完成矩阵乘法。给定已经按行完成量化的矩阵 ,以及尚未量化的浮点矩阵 。矩阵 的大小为 ,矩阵 的大小为 。需要计算矩阵乘积 ,并以全整型量化形式输出 的每个元素。
矩阵 的每一行已经附加了缩放因子 。矩阵 需要按列进行 8 比特量化:对第 列,先取该列所有元素绝对值的最大值,令该列缩放因子 ,然后按量化公式得到该列整数表示,并裁剪到 [-127,127];同时将 表示为 的形式。
对于结果矩阵 中第 行第 列的元素,其量化值为 第 行量化整数与 第 列量化整数的点积。其缩放因子为两个缩放因子的乘积:
因此该元素需要输出三个整数:点积结果、、。点积结果不需要限制在 [-127,127] 内,缩放因子也无需约分。
约束条件:
- 矩阵 每行给出的已量化整数以及矩阵 量化后的整数均在
-127到127之间。 - 缩放因子中, 的取值范围为
0到127, 的取值范围为0到30。 - 矩阵 使用
8比特量化,量化后的整数范围为-127到127。
输入格式
第一行包含三个整数 m、k、n,依次表示矩阵 的行数、矩阵 的列数(也是矩阵 的行数)、矩阵 的列数。
接下来 m 行,每行包含 k+2 个整数。前 k 个整数是矩阵 当前行已经量化的整数;最后两个整数依次为该行缩放因子的 a 和 b。
再接下来 n 行,每行包含 k 个浮点数。这些行按矩阵 的转置给出,其中第 i 行表示矩阵 的第 i 列。
输出格式
输出共 m 行。每一行对应矩阵 的一行,包含 个结果元素。每个结果元素由三个整数依次组成:量化值、缩放因子的 a、缩放因子的 b。同一行内所有整数之间用空格分隔。
示例
示例 1
输入:
2 3 2
1 2 3 1 0
10 -5 4 3 2
127.0 0.0 -127.0
0.0 127.0 64.0
输出:
-254 64 6 446 64 6
762 192 8 -379 192 8
说明:
矩阵 第 1 行的缩放因子为 ,即 ;第 2 行的缩放因子为 ,即 。
矩阵 两列的最大绝对值均为 127,因此 。将 表示为 时,四舍五入得到 。
计算点积如下:
- 第
1行第1列:。 - 第
1行第2列:。 - 第
2行第1列:。 - 第
2行第2列:。
结果缩放因子:
- 第
1行:,。 - 第
2行:,。
因此输出如样例输出所示。
示例 2
输入:
1 1 1
5 1 0
0.0
输出:
0 0 0
说明:
矩阵 唯一列的元素为 ,最大绝对值为 0,因此 ,量化后整数仍为 0,缩放因子表示为 ,即 。
矩阵 的唯一量化整数为 5,缩放因子为 。点积为 。结果缩放因子为 ,。
因此输出 0 0 0。
示例 3
输入:
1 2 1
5 -3 2 3
1.0 -2.0
输出:
701 130 15
说明:
矩阵 唯一列的元素为 ,最大绝对值为 2,所以 。
量化时:
- ,四舍五入为
64; - ,四舍五入为
-127。
缩放因子 按照 表示并四舍五入,得到 。
矩阵 的量化整数为 ,缩放因子为 。点积为 。结果缩放因子为 ,。
因此输出 701 130 15。
解题思路
第一步:理解问题本质
量化把浮点数 压成整数 :
其中 是缩放因子。本题要求缩放因子本身也不用浮点表示,而是写成两个整数:
这样做的好处是缩放因子之间的乘法完全变成整数运算:
于是结果矩阵 的元素可以拆成三部分独立计算:
- 量化值: 第 行的量化整数与 第 列的量化整数做点积,全程整数;
- 分子 ;
- 指数 。
注意题面要求“缩放因子无需约分”,所以 就是两个分子直接相乘,不要除以最大公约数。
第二步:暴力解法——先反量化,再浮点相乘
最符合直觉的做法是:
# 1. 用 s_A 把 A 的量化整数还原成浮点
A_float = [[q * (a_A / 2 ** b_A) for q in row] for row in A]
# 2. 对 B 的每一列做 8 比特量化,再反量化
B_float = [[q * (a_B[j] / 2 ** b_B[j]) for q in col] for col in B_quant]
# 3. 普通浮点矩阵乘法得到 C
C = [[sum(A_float[i][t] * B_float[t][j] for t in range(k))
for j in range(n)] for i in range(m)]
这个做法能算出大致正确的数值,但得不到题目要的输出:
- 输出要求的是“点积、、”三个整数,而不是 的浮点值;
- 反量化再相乘会引入浮点误差, 与 还要再从浮点值反推,容易在取整上差 1;
- 缩放因子一旦反量化成浮点,就丢失了“不约分”的信息。
所以正确路线不是“还原浮点”,而是把整数表示一路保持到底。
第三步:优化解法——按列量化 B,用整数点积
矩阵 已经是量化好的整数,缩放因子也直接给出,所以只需要处理 。
B 的量化必须按列进行。 第 列取
然后
代入 ,量化公式可以少做一次除法:
代码里直接用 value * 127 / max_abs,避免先算出 再除带来的额外舍入。
缩放因子 转成 。 这里的关键是 取多少。已知 必须是 的整数,而 最多 。直观上希望分母 尽量大(精度更高),但不能让分子超过 :
例如样例 3 中 :
| 四舍五入后的 | 是否可用 | ||
|---|---|---|---|
| 可以,但分母偏小 | |||
| 可以,且是最大的可用 | |||
| 超过 ,不可用 |
所以取 、。
结果矩阵。 对每个 :
点积是纯整数运算,不会有任何误差;题面明确说点积不需要裁剪到 。
第四步:完整实现细节
几个必须单独处理的情况:
-
整列为 0:,,此时不能做除法。规定量化值全为 ,缩放因子写成 ,即 (与样例 2 一致)。
-
四舍五入的方向:Python 内置的
round是“银行家舍入”(round(63.5) == 64、round(64.5) == 64),与题面的四舍五入不一致。需要一个.5时远离 0 的取整函数:def round_half_away(value):
if value >= 0:
return int(math.floor(value + 0.5))
return int(math.ceil(value - 0.5))样例 3 里 必须得到 。
-
B 是按列给出的:输入的后 行,每行是矩阵 的一个列,不是一行。读进来时不要再转置一次。
-
可能为 : 那一行的缩放因子分子为 时,结果的 ,但 仍然要把 加上,不能直接输出
0 0 0。 -
输出格式:共 行,每行 组 “点积 ”,组与组之间用一个空格分隔。
完整代码实现
import math
import sys
# 8 比特量化,缩放因子的分子上限。
MAX_QUANT = 127
# 缩放因子的指数上限,题面给出 0 <= b <= 30。
MAX_SHIFT = 30
def round_half_away(value: float) -> int:
"""四舍五入(.5 向远离 0 的方向取整)。"""
if value >= 0:
return int(math.floor(value + 0.5))
return int(math.ceil(value - 0.5))
def quantize_column(values, max_abs):
"""
把一列浮点数量化成 [-127, 127] 内的整数。
s_B = max_abs / 127,所以 value / s_B = value * 127 / max_abs。
直接用乘法再除,避免先算出 s_B 引入一次额外的舍入。
"""
if max_abs == 0.0:
# 整列都是 0:缩放因子退化为 0,量化值也只能是 0。
return [0] * len(values)
result = []
for value in values:
q = round_half_away(value * MAX_QUANT / max_abs)
# 理论上 |value| <= max_abs 已经保证 |q| <= 127,
# 这里再裁剪一次,挡住浮点误差带来的 128 / -128。
if q > MAX_QUANT:
q = MAX_QUANT
elif q < -MAX_QUANT:
q = -MAX_QUANT
result.append(q)
return result
def column_scale(max_abs: float):
"""
把 s_B = max_abs / 127 表示成 a / 2^b。
b 取“四舍五入后的 a 不超过 127”的最大值:b 越大,分母越细,
表示精度越高;直到 a 再往上就会越过 8 比特缩放因子的上限。
"""
if max_abs == 0.0:
# 与样例 2 一致:0 表示为 0 / 2^0。
return 0, 0
scale = max_abs / MAX_QUANT
# 从大到小找第一个满足 a <= 127 的 b,找到即可返回。
for shift in range(MAX_SHIFT, -1, -1):
a = round_half_away(scale * (1 << shift))
if a <= MAX_QUANT:
return a, shift
# 防御性分支:s_B 大到即使 b = 0 也超过 127(题面约束下不会出现)。
return MAX_QUANT, 0
def solve():
"""读入 A、B,按列量化 B,输出 C 的每个元素与对应缩放因子。"""
data = sys.stdin.buffer.read().split()
if len(data) < 3:
return
it = iter(data)
m = int(next(it))
k = int(next(it))
n = int(next(it))
# A 的每行:k 个量化整数 + 缩放因子的 a、b。
quant_a = []
scale_a = []
for _ in range(m):
row = [int(next(it)) for _ in range(k)]
a = int(next(it))
b = int(next(it))
quant_a.append(row)
scale_a.append((a, b))
# B 按列给出:第 j 行就是 B 的第 j 列。
quant_b = []
scale_b = []
for _ in range(n):
values = [float(next(it)) for _ in range(k)]
max_abs = max(abs(v) for v in values)
quant_b.append(quantize_column(values, max_abs))
scale_b.append(column_scale(max_abs))
lines = []
for i in range(m):
row_a = quant_a[i]
a_a, b_a = scale_a[i]
parts = []
for j in range(n):
col_b = quant_b[j]
# 全整型点积:两个量化整数数组逐项相乘再求和。
dot = sum(row_a[t] * col_b[t] for t in range(k))
a_b, b_b = scale_b[j]
# 缩放因子相乘:分子相乘,指数相加,绝不约分。
parts.append(f"{dot} {a_a * a_b} {b_a + b_b}")
lines.append(" ".join(parts))
print("\n".join(lines))
if __name__ == "__main__":
solve()
NumPy 版本
以下版本使用 NumPy。只有在评测环境允许第三方库时才能提交;CodeFun2000 的普通 Python 环境不一定安装 NumPy。
思路与上面的纯 Python 版完全一致,只是把逐个元素的循环换成矩阵运算:整列量化用广播一次做完, 个候选 也一起算,最后用一次 quant_a @ quant_b.T 得到全部 个点积。量化整数都在 内,int64 累加既不会溢出,也没有任何浮点误差。
import sys
import numpy as np
# 8 比特量化,缩放因子的分子上限。
MAX_QUANT = 127
# 缩放因子的指数上限,题面给出 0 <= b <= 30。
MAX_SHIFT = 30
def round_half_away(values):
"""
四舍五入(.5 向远离 0 的方向取整),支持 ndarray 广播。
直接用 np.round 是错误的:它是“银行家舍入”,round(63.5) 会得到 64、
round(64.5) 也会得到 64。这里对非负数加 0.5 向下取整、对负数减 0.5
向上取整,与纯 Python 版逐元素等价。
"""
values = np.asarray(values, dtype=np.float64)
return np.where(values >= 0.0, np.floor(values + 0.5), np.ceil(values - 0.5))
def quantize_columns(values, max_abs):
"""
把 n 列浮点数量化成 [-127, 127] 内的整数。
values 的形状是 n x k(第 j 行是 B 的第 j 列),max_abs 是每列的 M_j。
s_B = M_j / 127,所以 value / s_B = value * 127 / M_j:整列一次性广播做完,
除法次序与纯 Python 版一致,舍入结果逐位相同。
"""
# 整列全 0 时 M_j = 0,先换成一个安全分母避免除零告警,稍后再把这行置 0。
safe_max = np.where(max_abs == 0.0, 1.0, max_abs)
scaled = values * MAX_QUANT / safe_max[:, None]
# 裁剪挡住浮点误差带来的 128 / -128;点积结果不裁剪,那是另一回事。
quant = np.clip(round_half_away(scaled), -MAX_QUANT, MAX_QUANT).astype(np.int64)
quant[max_abs == 0.0] = 0
return quant
def column_scales(max_abs):
"""
把每列的 s_B = M_j / 127 表示成 a / 2^b,返回 (a, b) 两个向量。
b 取“四舍五入后的 a 不超过 127”的最大值。这里是 31 个 b 一起算、
再沿 b 从大到小取第一个可用值,等价于纯 Python 版的倒序循环。
"""
n = max_abs.shape[0]
# b 从 30 递减到 0;argmax 取第一个 True,正好就是最大的可用 b。
shifts = np.arange(MAX_SHIFT, -1, -1)
scale = max_abs / MAX_QUANT
a_all = round_half_away(scale[None, :] * (1 << shifts)[:, None]) # (31, n)
valid = a_all <= MAX_QUANT
cols = np.arange(n)
idx = valid.argmax(axis=0)
a_b = a_all[idx, cols].astype(np.int64)
b_b = shifts[idx].astype(np.int64)
# 防御性分支:s_B 大到即使 b = 0 也超过 127(题面约束下不会出现)。
no_valid = ~valid.any(axis=0)
a_b[no_valid] = MAX_QUANT
b_b[no_valid] = 0
# 与样例 2 一致:整列全 0 记作 0 / 2^0。
zero_cols = max_abs == 0.0
a_b[zero_cols] = 0
b_b[zero_cols] = 0
return a_b, b_b
def solve():
"""读入 A、B,按列量化 B,用矩阵乘法一次算出全部点积。"""
tokens = sys.stdin.buffer.read().split()
if len(tokens) < 3:
return
m, k, n = (int(x) for x in tokens[:3])
pos = 3
# A 的每行:k 个量化整数 + 缩放因子的 a、b,整块读成 (m, k+2)。
block = np.array(tokens[pos:pos + m * (k + 2)], dtype=np.int64).reshape(m, k + 2)
pos += m * (k + 2)
quant_a = block[:, :k]
a_a = block[:, k]
b_a = block[:, k + 1]
# B 按列给出:后 n 行每行就是 B 的一个列向量,直接读成 n x k。
values = np.array(tokens[pos:pos + n * k], dtype=np.float64).reshape(n, k)
max_abs = np.abs(values).max(axis=1)
quant_b = quantize_columns(values, max_abs)
a_b, b_b = column_scales(max_abs)
# 点积 = A 的行 × B 的列,一次 matmul 就能算出 m x n 个结果。
# A、B 的量化整数都在 ±127 内,int64 累加不会有任何溢出或精度损失。
dot = quant_a @ quant_b.T
# 缩放因子:分子相乘(不约分),指数相加。
a_out = np.outer(a_a, a_b)
b_out = b_a[:, None] + b_b[None, :]
# 每个元素输出“点积 a b”三个整数,按行拼成一条字符串。
triples = np.stack([dot, a_out, b_out], axis=-1).reshape(m, -1)
for row in triples.tolist():
print(" ".join(map(str, row)))
if __name__ == "__main__":
solve()
示例推演
样例 1
输入 、、。
整理 A:
| 行 | 量化整数 | 缩放因子 | ||
|---|---|---|---|---|
| 1 | ||||
| 2 |
按列整理 B(输入已经是列):
| 列 | 元素 | ||
|---|---|---|---|
| 1 | |||
| 2 |
两列的 都是 ,所以 ,量化值就是原来的整数:
- 第 1 列:;
- 第 2 列:。
求 :
| 四舍五入 | 是否可用 | ||
|---|---|---|---|
| 可用 | |||
| 超过 |
所以 、。
点积:
- 第 1 行第 1 列:;
- 第 1 行第 2 列:;
- 第 2 行第 1 列:;
- 第 2 行第 2 列:。
缩放因子:
- 第 1 行:,;
- 第 2 行:,。
按“点积 ”拼接:
-254 64 6 446 64 6
762 192 8 -379 192 8
样例 2
唯一的列是 ,,于是 ,量化值 ,缩放因子写作 ,即 。
点积为 ,,,输出:
0 0 0
样例 3
唯一的列是 ,,所以 。
量化:
- ,远离 0 取整得 ;
- ,取整得 。
缩放因子: 前面已经算过, 时 , 时 ,所以 。
点积: 用 的量化整数 :
结果缩放因子:,于是
输出:
701 130 15
复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 反量化为浮点再矩阵乘 | 结果不是题目要求的整数三元组 | ||
| 按列量化 + 整数点积 | 本题采用;无浮点累积误差 | ||
| 逐元素输出 | 与矩阵乘同阶,可忽略 |
其中 用于量化 的每一列(每列求一次最大值、 次量化), 用于 个点积。主循环里 b 的搜索最多 次,是常数。
易错点总结
1. B 是按列给出的
输入的后 行,每行就是矩阵 的一个列向量。不要把它当成行再转置一次,否则量化时求的是“行的最大绝对值”,结果全错。
2. 量化时除以的是
等价于乘以 。写成 value / (max_abs / 127) 与 value * 127 / max_abs 在数学上相同,但后者少一次中间舍入,推荐后者。
3. .5 要远离 0 取整
Python 的 round 是银行家舍入:round(64.5) == 64。本题要求四舍五入, 必须变成 , 必须变成 。必须自己实现 round_half_away。
4. 量化值要裁剪,点积不要裁剪
的量化结果要 clip 到 ;而 的点积是累加结果,题面明确说不需要限制在 。样例 1 的 就超过了 。
5. 不是固定值
不能写死 或 。正确做法是取“使四舍五入后的 不超过 的最大 ”。样例 1 得到 ,样例 3 得到 。
6. 时不能做除法
整列都是 时 ,除法会抛异常。此时量化值全为 ,且按样例 2 的约定输出 。
7. 时 仍要相加
结果的分子是 ,只要有一个为 就是 ;但指数是 ,与分子是否为 无关。写成“ 就直接输出 0 0 0”是错的。
8. 缩放因子不要约分
不能约成 或 , 也不能跟着减。题面明确要求“缩放因子也无需约分”。
9. 输出格式是“三个整数一组”
每个结果元素占三个数:点积、、。同一行的 组之间用空格分隔,共输出 行。
扩展思考
- 为什么 限制在 以内: 与量化值共享同一个 8 比特整数范围,便于用
int8或定点指令打包存储; 对应最大精度。 - 的上限为什么是 : 表示分母的二进制指数, 已经能覆盖常见的动态范围;再大就需要更宽的整数类型。
- 量化误差:对称量化的最大绝对误差是 ,相对误差与数值大小有关。整列为 0 时不需要缩放因子,这也是要单独处理的原因。
- 非对称量化:如果数据整体偏移(例如全为正的大数),可以引入零点 ,用 ,在同样比特数下精度更高。
- 更省算力的做法:若 固定,可以把点积结果直接右移相应位数得到近似的浮点值;本题要求保留原始三元组,所以不做移位。
- 批量矩阵乘:实际推理框架会把多个 打包成向量化指令,一次算完整行;本题只需要按定义逐个元素输出。