理解 Lasso(一):Offline Memory Checking

假设我们有一个公开的 Table 向量 (长度为 ),和一个 Lookup 向量 (长度为 ),此外还有一个索引向量 (长度为 ),如何证明下面的 Indexed Lookup 关系?

虽然我们有 Plookup, Caulk/Caulk+, Baloo, Logup,cq 等等方案可以直接使用,但 Offline memory checking 提供了一个更直观的新视角来看待 Lookup Argument。

1. Memory-in-the-head

我们把 Lookup 的过程看成是一个虚拟机读取内存的过程。如果一个 Lookup 关系成立,那么我们一定可以构造出一个合法的虚拟机执行序列,这个序列中的每一步都是合法的内存读取操作,从而证明每一个执行步读取的值 都是出自只读内存 ,即证明了 Lookup 关系。如果我们关心内存读取的地址的话,那么我们就实现了一个 Indexed Lookup Argument。对于一个 Prover,她可以在本地构造虚拟机的执行序列 ,并向 Verifier 证明 的合法性。而对于 Verifier 而言,我们比较关心 Verifier 如何在不需要遍历 的情况下,验证这个长度为 的执行序列。 因此,一个 Lookup 关系的证明,就转化为一段「只读内存」读取日志的正确性证明。换句话说,如果一串内存读取过程是正确的(符合虚拟机运行规则,并可以复现),那么就能推出这样的结论:如果每次读取的内容都是合理的,那么读取的值一定存在于原始内存(表格)中。这种证明思路可以形象地被称为「Memory in the head」,Prover 向 Verifier 证明一个头脑中想象出来的内存的读写合法性。

下面是一个虚拟机执行序列的例子,也是一个确定性状态转移关系:

其中 代表内存读取操作,按顺序读出来 。虚拟机内存状态 是一个三元组 的集合,一个三元组包含内存地址 ,内容 和计数器 三部分。注意到我们为每一个内存单元都附加一个计数器,标记着这个内存单元被读取的次数,这个计数器的作用是确保只读内存在读取过程中仍然会发生实质性的状态更新,从而提供了执行步 的验证信息。

内存初始状态 中的元素如下:

由于每一次内存的读取(虚拟机的执行)都会修改相应地址上的计数器,让计数器加一,因此我们规定虚拟机在每一次读写内存的前后,必须抛出两个日志(或者理解为事件),内存读取日志 与内存更新日志 。两者也同样都是一个三元组 ,包含内存读取地址 ,读取内容 ,和计数器的值

对于内存读取日志 中的 为读取时刻内存单元 中的计数器值; 中的 为更新后的计数器值。换句话说,我们也可以理解 发生在一次内存读取之前, 发生在一次内存读取后,两个事件之前内存单元因为一次「读取」而将计数器的值加一。这一前一后两个日志的作用是约束每一次内存读取的合法性。怎么做到的呢?我们先看一个例子,假如内存的长度为 4,存放的内容为 ,假如我们要依次从内存中读取 ,那么会产生下面的日志序列

三次读取产生的状态转移如下:

2. 内存读取的验证

现在思考下,一个 Prover 如何向 Verifier 证明虚拟机执行序列的合法性?下面我们给出虚拟机执行合法性的四个条件:

解读如下:

  • 条件(1): 虚拟机执行必须从一个初始状态 开始,即内存中依次存放着表格内容 ,并且计数器都置为零;
  • 条件(2): 存在一个正确的终状态 ,并且 中的内存数据 没有被修改;
  • 条件(3): 对于每一个 日志,在「该事件之前」都会有一个成对出现的 日志,他们记录的读取值相等 ,但
  • 条件(4): 对于每一个 ,如果它是地址 上的第一次读取,读取值应该等于内存初始状态 ;如果它是地址 上的第二次或后续读取,那么在「该事件之前」一定有一个对应的 日志,使得

这四个条件是否完备充分呢?我们可以试着用归谬法推理下:

假如存在有一个 中的读取数值 非法, 即 ,并且此刻计数器为

假如 ,那么根据条件 (4),又因为条件 (1),这与假设矛盾。

另一种情况是 ,那么根据条件 (4),一定存在一个 日志,使得 的计数器值为 ,再根据条件 (3),一定存在一个 日志,使得 。以此递归地推理下去,每次 递减一,最后我们一定可以得到某个 ,于是根据条件 (1) 可得, ,这与条件(1) 的正确性()矛盾。

到此推理完毕,存在有一个非法读取日志 的假设不正确,因此我们得出结论:满足上面四个条件的虚拟机执行序列中,不可能出现读取一个错误的值的情况。

因此,只要 Prover 能够证明,(1) 初始状态 正确,并且 (2) 每一步读取日志是自洽的,那么我们可以证明读取过程就是不可伪造的。Offline Memory Checking 提供了一个漂亮的约束等式,同时满足上面四个条件:

我们进一步分析下这个约束等式,先展开下等式左右两边的定义:

这个等式约束是关于四个多重集合(Multiset)之间的关系。容易看出,初始状态约束 条件(1) 和 终状态约束 条件(2) 已体现在上面的等式中。接下来我们简单分析下,上面这个等式如何保证了 条件(3)条件(4)

先看下条件(3),对于每一个 (出现在等式左侧),那么就有一个成对出现的 (出现在等式右侧),两个日志的差别是右侧 的计数器值少一。看下 条件(4),如果某个 中的计数器值为零,那么在等式左边一定有一个相同的三元组元素,出现在 集合中;如果 中的某个元素的计数器值大于零,那么这个元素一定出现在等式左边的 中。

注意到,等式右边来自于 中的每一个元素,可能出现在左边的 中,这意味着该元素所对应的内存单元从未被读取过; 集合元素也可能出现在 中,这意味着该元素的计数器值等于最后一次内存单元计数器的更新值。

最后我们分析下 Prover 和 Verifier 的输入。对于 Verifier 而言, 属于 Public inputs,这样 Verifier 可以验证 条件(1),Verifier 要求 Prover 提供 向量,从而构造 ,验证 条件(2)。此外 Public inputs 还要包括承诺 ,以便 Verifier 同态地验证 Multiset 等价关系。而日志集合 由 Prover 构造,并给出其中计数器部分的承诺 ,从而允许 Verifier 来验证正确性条件 (3)。而 Verifier 也可以根据 还有 ,同态地构造出 的承诺。

接下来我们利用 Memory-in-the-head 的思路,设计一个 PIOP 协议,实现 Indexed Lookup Argument。

3. 构造 Lookup Argument 协议

我们把这四个集合 看成是三列矩阵,并且所有的矩阵列向量都编码为多项式。其中 矩阵的三列记为 , ,这里注意 在 中的 value 一列必须等于表格向量 。矩阵 为虚拟机的终止状态,由于虚拟机内存为只读内存,因此 两列保持不变,但是内存单元计数器被更新到了 ,编码为 ,多项式编码的 Domain 记为

日志矩阵 的第一列为读取的地址序列,它必须等于地址向量 ,第二列为读取的值,等于 ,而第三列 为 Prover 维护的计数器向量,编码为 。再看下矩阵 ,其每一行为一条内存更新日志,其中第三列为更新后的计数器值,这个值编码为 ,并应该满足下面的约束:

下面是 Offline Memory Checking 的约束等式:

我们可以用多项式之间的约束关系描述下 Multiset 等价约束:

其中四个二元多项式的定义如下:

其中

我们可以再使用两个 Verifier 提供的随机挑战数 ,把上面的多项式等价关系归结到两个 Grand Product 之间的等价关系。而 Grand Product Argument,我们可以有多种方案来完成。例如我们可以采用 Plonk 协议中的 Grand Product 子协议来完成,也可以采用 GKR 协议,或者 论文 [Quarks, SL20] 中 基于 Sumcheck 的协议。

协议描述

公共输入:

第一轮

Prover 模拟内存读取流程得到终状态 ,得到

Prover 计算 的承诺 , Prover 计算 的承诺

Prover 发送

第二轮

Verifier 发送挑战数

Prover 计算读取/更新日志向量 , ,

Prover 计算

Prover 和 Verifier 利用 Grand Product Argument 来证明下面的等式:

验证

Verifier 计算 ,并验证 Grand Product Argument

这里

4. 对比理解 Offline Memory Checking

与 Plookup, Caulk/Caulk+, flookup, Baloo, CQ 相比, Memory-in-the-head 方式证明 Lookup 是一个巧妙且直观的想法。不过我们会想知道他们之间有何差别?

这一节,我们从 Plookup 的角度出发,换一个角度来理解 Offline memory checking。

我们先假设 中不存在重复元素,那么我们可以采用 Vanishing Form 的方式来编码 为多项式:

Prover 可以通过下面的等式来证明

但是如果考虑 中存在重复元素,那么用 Vanishing Form 编码的多项式就不满足上面的等式约束了。处理重复元素是 Lookup Argument 中比较棘手的问题。为了修补这个方案,我们要为表格向量 和查询向量 分别扩展一个新的列向量,称为计数器列 。每当 中出现重复读取同一个表格元素时,我们可以通过计数器列来区分这两次不同的读取。比如 中有两次对 的查询,那么我们可以定义一个扩展后的查询向量 :

扩展后的向量中的每一个元素是一个二元组,其中第二部分为计数器值。 扩展查询中的前两个查询 ,虽然查询值都为 ,但是由于计数器会按顺序加一,因此,两个二元组不再相等。

同样,我们也可以定义一个扩展后的表格向量 :

那么我们会问下面的公式会成立吗?

很显然,它不成立,因为等式左边有 ,而右边的集合中不包含这个元素。显然我们需要在公式的右边补上 。换句话说,我们需要在右边补上那些由于计数器累加产生的重复表项,记为

但是这个向量 不能由 Prover 提供。为了防止 Prover 作弊, 必须由 Verifier 来提供。那么接下来,我们面临的问题是,Verifier 并不清楚哪些 重复,并且也不能知道重复了几次。这个问题该如何解决?

我们可以把查询 看成是一个「消耗」表格元素的机器,每次查询 ,都会消耗掉一个对应的表格元素 。我们把向量 看成一个可以「产生」新元素的机器,每次出现一个对 重复查询的记录,比如 ,那么 就会自动产生出一个新的元素,记为 ,供下一次查询「消耗」。这样我们就可以让 Verifier 在等式左边添加 个元素,正好对应 的元素,但是所有元素中的计数器都自增一。这样,等式左边的集合 就可以由 Verifier 自行构造:

这个公式成立 ,我们继续可以用 Vanishing Form 的方式来表达这个子集关系,比如对上面的例子,我们可以得到下面的多项式等式:

其中 为 Verifier 提供的随机挑战数,用来合并表格 的二元组为一个单值。这里 编码了那个能自动产生新元素的机器,它的每一个因子都是一个 元素。下面是 多项式的定义:

多项式等式右边的多项式 会有哪些元素呢? 恰好包含有所有的等待被消耗的 元素,其中包括始终没有被查询过的计数器为零的元素,还包括被查询过的,但是又被 复制产生的元素。于是我们得到了下面的等式约束:

下面我们证明上面的等式保证了 中的每一个元素都是 中的元素。

我们用反证法,假如存在一个 ,那么根据上面的等式,一定存在一个计数器 出现在等式的左边。这时候如何 ,那么 ,与假设矛盾。那么这时候可以断定 ,那么等式右边一定存在一个 ,才会让左边出现 ;同理可推,左边一定存在一个 ,那么右边一定会出现一个 。以此类推,我们一定可以得到:等式左边会出现 ,于是 ,这又与初始初始假设矛盾。

这个思路与 Memory-in-the-head 几乎一摸一样,除了我们不考虑表格的索引问题。基于这个思路,我们可以构造一个 Unindexed Lookup Argument。

对比 Plookup

回忆下 Plookup 的方案,对于 ,如果我们要证明 ,那么 Prover 需要引入一个中间向量 ,长度为 。它是 的一个重新排序,按照 中原有项的顺利进行排列。然后 Prover 证明下面的 Multiset 等价关系:

这个方案和 Memory-checking 相比,Multiset 约束等式两边的集合元素数量都为 ,但 Plookup 需要多引入一个中间辅助向量 ,而后者则需要引入一个计数器向量 。计数器向量节省了 Prover 在排序上的工作开销,另一方面,向量 中的值较小且规律,Prover 计算其承诺会更有优势(如 Perdesen 承诺或者 KZG10)。

5. 小结

本文介绍了如何采用传统的 Offline Memory Checking 技术构造 Lookup Arguments,其中关于 Memory Checking 的公式 蕴含着非常巧妙的思想。

References