无法使用带有 -O3 选项的 clang 在 C++ 中执行尾递归

Unable to do tail recursion in C++ using clang with -O3 option

我无法告诉 clang 编译器在我的 C++ 代码中执行尾递归优化。我看到了这个 post Which, if any, C++ compilers do tail-recursion optimization?,其中的建议是将 -O3 标志与 clang 一起使用,它会做到这一点。但是尽管如此,对于以下素数测试代码的大量输入,我还是有堆栈溢出。

#include <iostream>
#include <chrono>
#include <gmp.h>
#include <string>

bool mpz_is_divisible(mpz_t n, mpz_t d);

void mpz_sqr(mpz_t res, mpz_t x) {
   //computes square of x and puts the result into res

   mpz_pow_ui(res, x, 2);
}

//the below function causes stack overflow for large inputs

void smallest_divisor(mpz_t n, mpz_t div, mpz_t div_sqr, mpz_t smallest_div) {
 //finds the smallest number which divides n and puts the result into smallest_div

  mpz_sqr(div_sqr, div);
  
  if (mpz_cmp(div_sqr, n) > 0) {
     mpz_set(smallest_div, n);
     return;
  }

  if (mpz_is_divisible(n, div)) {
    mpz_set(smallest_div, div);
    return;
  }

  mpz_add_ui(div, div, 1);
  smallest_divisor(n, div, div_sqr, smallest_div); //<-- should do tail recursion optimisation?
}

bool mpz_prime_test_basic(mpz_t n) {
  //checks if n is prime

  mpz_t div, div_sqr, smallest_div;

  mpz_inits(div, div_sqr, smallest_div, NULL);
  mpz_set_ui(div, 2);

  smallest_divisor(n, div, div_sqr, smallest_div);

  if (mpz_cmp(smallest_div, n) == 0) {
    mpz_clear(div);
    mpz_clear(div_sqr);
    mpz_clear(smallest_div);
    return true;
  }
  mpz_clear(div);
  mpz_clear(div_sqr);
  mpz_clear(smallest_div);
  return false;
}

bool mpz_is_divisible(mpz_t n, mpz_t d) {
  //checks if n is divisible by d
  mpz_t rem;
  mpz_init(rem);
  mpz_tdiv_r(rem, n, d);
  int cmp = mpz_cmp_si(rem, 0); //checks if remainder is equal to 0
  if (cmp == 0) return true;
  return false;
}

int main() {
  std::string num_str;
  mpz_t num;
  mpz_init(num);
  std::cout << "Enter number to check" << '\n';
  std::cin >> num_str;
  mpz_set_str(num, num_str.c_str(), 10);

  bool is_prime = mpz_prime_test_basic(num);

  if (is_prime) {
    std::cout << num_str << " is a prime\n";
  } else {
    std::cout << num_str << " is not a prime\n";
  }

  return 0;
}

我现在正在阅读 SICP 的第 1 章,其中有一个关于使用 O(√n) 算法检查素数的练习 (1.22)。尾递归由 Scheme 标准强制执行,因此对于大输入没有问题,但我在 C++ 中做同样的问题,对于大数,最小除数函数消耗堆栈。我正在使用 GMP 库进行大整数运算。

是否可以在这样的程序中强制执行尾递归?

谢谢

原则上,所有主要的C++编译器都会进行尾调用优化。您的代码应该会从中受益。

查看了从您在 Godbolt with Clang 11 上的代码获得的程序集,看来 -O3 标志确实导致了尾代码优化。您的递归函数编译为:

smallest_divisor(test, test, test, test): # @smallest_divisor(test, test, test, test)
  push rax
.LBB1_1: # =>This Inner Loop Header: Depth=1  <------ TCO Loop 
  mov edi, 2
  call mpz_pow_ui(test, test, unsigned int)
  call mpz_cmp(test, test)
  test eax, eax
  jg .LBB1_4                                  <------ go to return sequence
  call mpz_init(test)
  call mpz_tdiv_r(test, test, test)
  xor edi, edi
  call mpz_cmp_si(test, int)
  test eax, eax
  je .LBB1_4
  mov edi, 1
  call mpz_add_ui(test, test, unsigned int)
  jmp .LBB1_1                                <--- no recursive call but loop
.LBB1_4:                                     <--- this is the end of recursion
  pop rax
  jmp mpz_set(test, test) # TAILCALL         <-- no ret, but another tail call :-)

我无法测试您的代码,因为我没有库,也没有时间花时间获取和安装它。

根据您描述的症状,我可以看出以下可能的根本原因:

  • 库中有一些UB,导致段错误
  • 由于编译器设置,优化被禁用。例如,如果您使用 XCode,则需要转到菜单“产品”>“方案”>“编辑方案...”,然后选择“发布”作为构建配置。

尝试了 XCode 中的程序集(产品 > 执行操作 > Assemble),生成的程序集代码显示了 TCO 的证据:

__Z16smallest_divisor4testS_S_S_:       ## @_Z16smallest_divisor4testS_S_S_
Lfunc_begin1:
    .loc    67 29 0                 ## Test0105-1/main.cpp:29:0
    .cfi_startproc
## %bb.0:
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
LBB1_1:                                 ## =>This Inner Loop Header: Depth=1
Ltmp3:
    .loc    67 24 4 prologue_end    ## Test0105-1/main.cpp:24:4
    movl    , %edi
    callq   __Z10mpz_pow_ui4testS_j
   ....
    movl    , %edi
    callq   __Z10mpz_add_ui4testS_j
Ltmp13:
    .loc    67 0 3 is_stmt 0        ## Test0105-1/main.cpp:0:3
    jmp LBB1_1                             <<<<<<<<<<<< Tail call optimization (loop)
LBB1_4:
    popq    %rbp
Ltmp14:
    jmp __Z7mpz_set4testS_      ## TAILCALL

mpz_is_divisible 有点奇怪。其一,您忘记释放 rem 占用的内存。即使您添加 mpz_clear(rem) 调用,您也不会在 smallest_divisor 中获得 TCO。您需要做的是将其标记为 __attribute__((noinline)) 让 Clang 进行优化(尽管 GCC 没有它)。很奇怪,也没有实际意义,因为 mpz_is_divisible 只是 mpz_divisible_p.

的一个更糟糕的版本
void smallest_divisor(mpz_t n, mpz_t div, mpz_t div_sqr, mpz_t smallest_div) {    
    mpz_sqr(div_sqr, div);
    if (mpz_cmp(div_sqr, n) > 0) {
        mpz_set(smallest_div, n);
        return;
    }
    if (mpz_divisible_p(n, div)) {
        mpz_set(smallest_div, div);
        return;
    }
    mpz_add_ui(div, div, 1);
    smallest_divisor(n, div, div_sqr, smallest_div); // gets TCO
}

Godbolt(从 trunk GCC 窃取 GMP,可能必须用当前日期更新一些路径才能使其工作)

我还大量清理了代码以生成新版本。特别是,我的 FP 直觉告诉我 smallest_divisor 实际上不应该是它自己的函数。它是属于 mpz_prime_test_basic 内部的递归工作者。我们可以使用 this answer 来编写局部递归定义。我在 Godbolt 上找不到 gmpxx.h,所以我也写了一个 mpz_class 的克隆。然后我们可以写

bool is_prime(mpz_t n) noexcept {
    mpz div(2L);
    fix{[](auto rec, mpz_t n, mpz_t div, mpz_t div_sqr) noexcept -> void {
        mpz_mul(div_sqr, div, div);
        if(mpz_cmp(div_sqr, n) > 0) mpz_set(div, n);
        else if(!mpz_divisible_p(n, div)) {
            mpz_add_ui(div, div, 1);
            rec(n, div, div_sqr);
        }
    }}(n, div, mpz());
    return mpz_cmp(div, n) == 0;
}

因为我们实际上并没有改变调用之间的参数,只是在它们后面进行变异,我们也可以只写

bool is_prime(mpz_t n) noexcept {
    mpz div(2L), div_sqr;
    fix{[&](auto rec) noexcept -> void {
        mpz_mul(div_sqr, div, div);
        if(mpz_cmp(div_sqr, n) > 0) mpz_set(div, n);
        else if(!mpz_divisible_p(n, div)) {
            mpz_add_ui(div, div, 1);
            rec();
        }
    }}();
    return mpz_cmp(div, n) == 0;
}

Godbolt(同样的警告)