【问题标题】:Improving performance for a TM simulator提高 TM 模拟器的性能
【发布时间】:2017-03-28 16:24:40
【问题描述】:

我正在尝试模拟很多 2 状态、3 符号(单向磁带)图​​灵机。每个模拟将有不同的输入,并将运行固定数量的步骤。该程序当前的瓶颈似乎是模拟器,它在不会停止的图灵机上占用了大量内存。

任务是模拟大约 650000 个 TM,每个 TM 有大约 200 个非空白输入。我尝试的最大步数是 10 亿 (10**9)。

下面是我正在运行的代码。 vector<vector<int> > TM 是一个转换表。

vector<int> fast_simulate(vector<vector<int> > TM, string TM_input, int steps) {
    /* Return the state reached after supplied steps */

    vector<int> tape = itotape(TM_input);

    int head = 0;
    int current_state = 0;
    int halt_state = 2;

    for(int i = 0; i < steps; i++){

        // Read from tape
        if(head >= tape.size()) {
            tape.push_back(2);
        }
        int cell = tape[head];
        int data = TM[current_state][cell];  // get transition for this state/input

        int move = data % 2;
        int write = (data % 10) % 3;
        current_state = data / 10;

        if(current_state == halt_state) {
            // This highlights the last place that is written to in the tape
            tape[head] = 4;
            vector<int> res = shorten_tape(tape);
            res.push_back(i+1);
            return res;
        }

        // Write to tape
        tape[head] = write;

        // move head
        if(move == 0) {
            if(head != 0) {
                head--;
            }
        } else {
            head++;
        }
    }

    vector<int> res {-1};
    return res;
}

vector<int> itotape(string TM_input) {
    vector<int> tape;
    for(char &c : TM_input) {
        tape.push_back(c - '0');
    }
    return tape;
}

vector<int> shorten_tape(vector<int> tape) {
    /*  Shorten the tape by removing unnecessary 2's (blanks) from the end of it.
    */
    int i = tape.size()-1;
    for(; i >= 0; --i) {
        if(tape[i] != 2) {
            tape.resize(i+1);
            return tape;
        }
    }
    return tape;
}

在性能或内存使用方面我有什么可以改进的地方吗?即使减少 2% 也会产生显着差异。

【问题讨论】:

  • 您通过价值传递了一些潜在的巨大vectors,因此,积极地复制它们。
  • 另外,你不断地在itotape 中使用push_backing,从而每秒改变tape 负载的大小,这是相当昂贵的。请注意,TM_input 是一个字符串,您知道其大小,因此您可以一次分配足够的内存。
  • 这个问题可能更适合codereview.stackexchange.com(前提是它是工作代码)。我不确定它是否归类为过于宽泛或基于意见,但可以肯定的是,其他人有同样问题的可能性很小
  • @ForceBru 好点,我可以通过引用传递磁带。我不知道磁带的最终大小,所以如果磁带在单词之后会立即增长,那么询问内存字符串的长度有什么意义吗?
  • @spyr03, TM_input 是一个std::string,它已经“知道”它的大小。我想说你已经知道itotape 的大小,所以你可以一次为tape 分配足够的内存,然后用数据填充它。目前你不断地通过连续分配内存来增加tape的大小,这会浪费很多时间。

标签: c++ performance performance-testing turing-machines


【解决方案1】:

确保在整个 TM 模拟期间不发生分配。

在程序启动时预分配一个全局数组,该数组对于磁带的任何状态都足够大(例如 10^8 个元素)。最初将机器放在此磁带阵列的开头。保持段 [0; R] 在当前机器模拟访问的所有单元格中:这允许您在开始新模拟时避免清除整个磁带阵列。

对磁带元素使用足够的最小整数类型(例如,如果字母肯定少于 256 个字符,则使用 unsigned char)。如果字母表非常小,也许您甚至可以切换到位集。这可以减少内存占用并提高缓存/RAM 性能。

避免在最内层循环中使用通用整数除法(它们很慢),只使用二次幂除法(它们变成位移)。作为最后的优化,您可以尝试从最内层循环中删除所有分支(对此有各种巧妙的技术)。

【讨论】:

  • 为什么每次模拟后“清零”数组不比分配内存慢?更改数组中的原语听起来是节省内存的好主意:) 我应该用谷歌搜索什么来找到避免分支的技术?
  • 分配比归零快,但分配不归零。所以,如果你分配每一个通道,你必须同时做(假设你也想将分配的内存归零)
  • 请注意,我的回答建议永远不要重新分配任何东西,并且只将磁带真正使用的部分归零。这是初始化新磁带所需的最小工作量。您也可以先 memcpy 输入字符串,然后仅将磁带的剩余部分归零(由之前的 TM 模拟使用)。
  • 在模拟过程中避免 push_back 像瘟疫一样。如果向量必须增长,但没有空间,那么整个事情都会被复制。这与“无分配”密切相关,但我想解释为什么它可能很可怕。 (特别是在一个巨大的向量上)
  • 为了避免你需要的分支:branchless min/max。请注意,检查state == halt 的分支应保持原样,因为它是可预测的(每次模拟仅发生一次)。 move == 0 的分支应该被删除。
【解决方案2】:

这是另一个具有更多算法方法的答案。

块模拟

由于您的字母表和状态数量很少,您可以通过一次处理磁带块来加速模拟。这与众所周知的speedup theorem 有关,尽管我建议使用稍微不同的方法。

将磁带分成每块 8 个字符。每个这样的块可以用 16 位数字表示(每个字符 2 位)。现在假设机器位于块的第一个字符或最后一个字符。然后它的后续行为仅取决于它的初始状态和块上的初始值,直到 TM 移出块(向左或向右)。我们可以预先计算所有(块值 + 状态 + 结束)组合的结果,或者在模拟过程中懒惰地计算它们。

这种方法一次可以模拟大约 8 个步骤,但如果你不走运,它每次迭代只能执行一个步骤(围绕块边界来回移动)。这是代码示例:

//R = table[s][e][V] --- outcome for TM which:
//  starts in state s
//  runs on a tape block with initial contents V
//  starts on the (e = 0: leftmost, e = 1: rightmost) char of the block
//The value R is a bitmask encoding:
//  0..15 bits: the new value of the block
//  16..17 bits: the new state
//  18 bit: TM moved to the (0: left, 1: right) of the block
//  ??encode number of steps taken??
uint32_t table[2][2][1<<16];

//contents of the tape (grouped in 8-character blocks)
uint16_t tape[...];

int pos = 0;    //index of current block
int end = 0;    //TM is currently located at (0: start, 1: end) of the block
int state = 0;  //current state
while (state != 2) {
  //take the outcome of simulation on the current block
  uint32_t res = table[state][end][tape[pos]];
  //decode it into parts
  uint16_t newValue = res & 0xFFFFU;
  int newState = (res >> 16) & 3U;
  int move = (res >> 18);
  //write new contents to the tape
  tape[pos] = newValue;
  //switch to the new state
  state = newState;
  //move to the neighboring block
  pos += (2*move-1);
  end = !move;
  //avoid getting out of tape on the left
  if (pos < 0)
      pos = 0, move = 0;
}

停机问题

评论说,TM 模拟预计要么很早就完成,要么将所有步骤运行到预定义的巨大限制。由于您要模拟许多图灵机,因此可能值得花一些时间来解决halting problem

可以检测到的第一种悬挂是:当机器停留在同一个地方而没有远离它时。让我们在模拟过程中保持 TM 的环绕,即距离 TM 当前位置

为 TM 的每个位置维护一个哈希表(我们稍后会看到,只需要 31 个表)。在每一步之后,将元组(状态,环境)存储在当前位置的哈希表中。现在是重要的部分:每次移动后,清除距离 TM >= 16 的所有哈希表(实际上,只需要清除一个这样的哈希表)。在每一步之前,检查(状态,周围)是否已经存在于哈希表中。如果是,则机器处于无限循环中。

您还可以检测到另一种类型的挂起:当机器无限向右移动,但永远不会返回时。为了实现这一点,您可以使用相同的哈希表。如果 TM 位于带索引 p 的磁带的当前最后一个字符处,则不仅在第 p 哈希表中检查当前元组(状态,周围),而且在(p-1)-th, (p-2)-th, ..., (p-15)-th 哈希表。如果找到匹配项,则 TM 将无限循环向右移动。

【讨论】:

  • 非常好的答案,考虑过一次模拟多个步骤,但我不知道如何实际去做。也感谢您的链接。关于循环 TM,我进行了初步检查,模拟 TM 约 10000 步,并检查它是否多次达到相同的内部状态(head、current_state、tape)。这并没有捕捉到永远正确运行的 TM,因此这将是一个巨大的改进。
【解决方案3】:

改变

int move = data % 2;

int move = data & 1;

一个是除法,另一个是位掩码,两者都应该在低位上给出 0 或 1。您可以在任何时候执行此操作,只要您有 % 的 2 次方。

你也在设置

cell = tape[head];
data = TM[current_state][cell]; 
int move = data % 2;
int write = (data % 10) % 3;
current_state = data / 10;

每一步,无论磁带[head] 是否已更改,甚至在您根本没有访问这些值的分支上。仔细查看哪些分支使用哪些数据,并且只在需要时更新内容。看完你写的:

    if(current_state == halt_state) {
        // This highlights the last place that is written to in the tape
        tape[head] = 4;
        vector<int> res = shorten_tape(tape);
        res.push_back(i+1);
        return res;
    }

^ 此代码没有引用“move”或“write”,因此您可以将“move”/“write”的计算放在其后,并且仅在 current_state !=halt_state 时才计算它们

if 语句的真分支也是优化分支。通过检查 not 停止状态,并将停止条件放在 else 分支中,您可以稍微改进 CPU 分支预测。

【讨论】:

  • 我可能是错的,但是现在不是所有的编译器都会自动将 mod 重写为 AND 吗?这似乎不太可能有多大贡献,因为我怀疑这是瓶颈。
  • 编译器不会为你做这个优化。那是因为 % 和 & 对于负数有不同的行为。 % 保留符号,因此 -1 的 %2 为 -1,而 & 仅返回选定的位。如果您需要知道奇数/偶数并且您不想担心负数,则 %2 是不安全的,并且编译器不会在您在其中传递负数的情况下进行此替换(这意味着它给出了不同的结果)。
  • 顺便说一句,我测试了 20 亿 %2 与 20 亿 &1 的循环。 Visual C++ 2015,全速优化。结果是节省了 20 秒。 %2 的循环为 55 秒,&1 的循环为 33 秒。因此,如果您运行 10 亿步并且每一步执行 %2,那么每次运行可以节省 10 秒,只需将 %2 替换为 &1。
  • 嗯,我的印象是,在 C++ 中,mod 对负值的行为是实现定义的,不必以这种方式舍入。老实说,我很惊讶!
  • 嗯,它可能会因实现而异,因此更有理由更喜欢 &1 来检查奇数/偶数。我真的很喜欢 Mike Acton 关于优化的视频,它们让你有不同的想法。事实上,我们所做的许多优化都依赖于特定领域的知识或假设。所以编译器实际上无法做到这一点。人们过于相信编译器的魔法而不是好的数据设计。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2011-05-24
  • 2011-07-31
  • 2011-08-28
  • 1970-01-01
  • 1970-01-01
  • 2012-05-14
  • 2011-06-04
相关资源
最近更新 更多