柚木

JavaScript 位运算,也许并没有那么快

写在最前。

昨天晚上在微信里收到了 R 神的建(tu)议(cao),感谢 R 神的吐槽。

------------------------------------分割线------------------------------

以下是原文:

在我的 上一篇文章 中提到了使用按位取反(~~)运算来实现取整操作,并对比了和规范的 Math.floor 的异同。

至于性能,很多文章推荐使用~~来进行取整,并指出:位运算要比使用 Math.floor 快很多。那么,到底快多少呢?

测试1:Math.floor and Math.ceil vs bitwise operations

很遗憾,位运算比 Math.floor 还慢。

测试2:Math.floor and Math.ceil vs bitwise operations

对比结果显示,Math.floor 是最快的。

测试3:Math.floor vs Math.round vs parseInt vs Bitwise

Math.floor 和 Math.round 都很快,但是 parseInt 确实里面最慢的。这不难理解,因为parseInt 的参数是字符串, 而字符串的处理相比数字要慢很多。

大部分的第一直觉是位运算肯定要比函数调用快,而且肯定会快很多很多。我之前也犯过类似的错误。

也有一部分人持相反观点,认为 ~~ 进行取反时,执行了两次操作,所以不一定会很快。

那么 V8 到底如何执行 Math.floor 的呢?我们可以通过搜索“20.2.2.16”或者“Math.floor”找到函数的源码 src/compiler/js-builtin-reducer.cc

// ES6 section 20.2.2.16 Math.floor ( x )
Reduction JSBuiltinReducer::ReduceMathFloor(Node* node) {
  JSCallReduction r(node);
  if (r.InputsMatchOne(Type::PlainPrimitive())) {
    // Math.floor(a:plain-primitive) -> NumberFloor(ToNumber(a))
    Node* input = ToNumber(r.GetJSCallInput(0));
    Node* value = graph()->NewNode(simplified()->NumberFloor(), input);
    return Replace(value);
  }
  return NoChange();
}

我们也可以去看关于 Math.floor 的测试用例:对 floor 的测试有两个,一个是 MathFloorWithNumber,一个是 MathFloorWithPlainPrimitive。

我们回到js-builtin-reducer.cc 的源码,如果传入原生类型,那么 ReduceMathFloor() 内部会调用 NumberFloor(),否则是 NoChange()。

NumberFloor 的处理在 src/compiler/typed-optimization.cc 文件:

Reduction TypedOptimization::ReduceNumberFloor(Node* node) {
  Node* const input = NodeProperties::GetValueInput(node, 0);
  Type* const input_type = NodeProperties::GetType(input);
  if (input_type->Is(type_cache_.kIntegerOrMinusZeroOrNaN)) {
    return Replace(input);
  }
  if (input_type->Is(Type::PlainNumber()) &&
      (input->opcode() == IrOpcode::kNumberDivide ||
       input->opcode() == IrOpcode::kSpeculativeNumberDivide)) {
    Node* const lhs = NodeProperties::GetValueInput(input, 0);
    Type* const lhs_type = NodeProperties::GetType(lhs);
    Node* const rhs = NodeProperties::GetValueInput(input, 1);
    Type* const rhs_type = NodeProperties::GetType(rhs);
    if (lhs_type->Is(Type::Unsigned32()) && rhs_type->Is(Type::Unsigned32())) {
      // We can replace
      //
      //   NumberFloor(NumberDivide(lhs: unsigned32,
      //                            rhs: unsigned32)): plain-number
      //
      // with
      //
      //   NumberToUint32(NumberDivide(lhs, rhs))
      //
      // and just smash the type of the {lhs} on the {node},
      // as the truncated result must be in the same range as
      // {lhs} since {rhs} cannot be less than 1 (due to the
      // plain-number type constraint on the {node}).
      NodeProperties::ChangeOp(node, simplified()->NumberToUint32());
      NodeProperties::SetType(node, lhs_type);
      return Changed(node);
    }
  }
  return NoChange();
}

NumberToUint32 是在 opcodes.h 中定义的,opcode 顾名思义就是操作码,是 V8 内部使用的类似汇编指令的代码

Type* OperationTyper::NumberToUint32(Type* type) {
  DCHECK(type->Is(Type::Number()));

  if (type->Is(Type::Unsigned32())) return type;
  if (type->Is(cache_.kZeroish)) return cache_.kSingletonZero;
  if (type->Is(unsigned32ish_)) {
    return Type::Intersect(Type::Union(type, cache_.kSingletonZero, zone()),
                           Type::Unsigned32(), zone());
  }
  return Type::Unsigned32();
}

我在 V8 引擎是如何知道 js 数据类型的?- justjavac 的回答 中曾简单介绍了 V8 引擎对于不同类型数据的存储,包括简单数据类型和复杂数据类型,相应的,V8 也定义了 simplified-operator 用于数字(整数、小数、布尔)。

在 JavaScript 中的数值在 V8 中表示为:

  • Tagged 数值

  • SMI (31位或32位)

  • float64 指针

  • Int32

  • Uint32

  • Float64

除此之外还有一些非 JavaScript 代码,一般用于中间代码使用

  • Int64
  • Uint64
  • Float32

Boolean 值有两种表示:

  • tagged pointer:和表示对象类似,使用一个 tagged pointer 来表示 js 中的 true 和 false
  • Bit:使用 untagged integer,0 或者 1(64位)

在 types.h 文件中我们可以看到,V8 内部大量使用宏和 C++ 位运算。因此并不是位运算不快,而是 V8 已经对很多操作进行了优化。

我的观点是:写业务代码,还是可读性最重要,把优化留给引擎去做吧。如果是写技术代码,可以适当的使用一些提示性能的奇技淫巧。