y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

HHVM での比較演算子の実装

HHVM の == の挙動が PHP と異なるという下記のブログ記事が興味深かったので、それぞれのソースコードを追ってみました。
HHVM 3.3.1とPHP 5.6.2の==の違いを調べてみた - hnwの日記

指摘されている現象の一つは、浮動小数点数と 16 進数値文字列の比較で PHP と HHVM の挙動が異なるというものです。記事では PHP 5.6.2 と HHVM 3.3.1 で確認されたとのことですが、master ブランチから最新の PHP (phpng) と HHVM を取得してビルドしたものでも同様の結果になりました。なお、もう一つの指摘である整数の範囲を超える数値文字列の比較については、master ブランチの HHVM では PHP と同じ挙動になっています。

PHP の実装

まず、PHP での処理の流れを追ってみます。PHP では、== 演算子は ZEND_IS_EQUAL 命令に変換され、Zend/zend_vm_def.h の以下の箇所で処理されます*1

ZEND_VM_HANDLER(17, ZEND_IS_EQUAL, CONST|TMP|VAR|CV, CONST|TMP|VAR|CV)
{
	USE_OPLINE
	zend_free_op free_op1, free_op2;
	zval *result = EX_VAR(opline->result.var);

	SAVE_OPLINE();
	fast_equal_function(result,
		GET_OP1_ZVAL_PTR(BP_VAR_R),
		GET_OP2_ZVAL_PTR(BP_VAR_R) TSRMLS_CC);
	FREE_OP1();
	FREE_OP2();
	CHECK_EXCEPTION();
	ZEND_VM_NEXT_OPCODE();
}

php-src/zend_vm_def.h at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub

fast_equal_function は Zend/zend_operators.h で以下のように実装されています。比較対象の値の実行時型が数値同士、または文字列同士の場合には、ここで高速に比較処理が実行されます。ただし、今回は浮動小数点数と文字列の比較になるので、最後の compare_function 関数が呼び出されます。

static zend_always_inline void fast_equal_function(zval *result, zval *op1, zval *op2 TSRMLS_DC)
{
	if (EXPECTED(Z_TYPE_P(op1) == IS_LONG)) {
		if (EXPECTED(Z_TYPE_P(op2) == IS_LONG)) {
			ZVAL_BOOL(result, Z_LVAL_P(op1) == Z_LVAL_P(op2));
			return;
            ...
	}
	compare_function(result, op1, op2 TSRMLS_CC);
	ZVAL_BOOL(result, Z_LVAL_P(result) == 0);
}

php-src/zend_operators.h at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub

compare_function 関数の実装は Zend/zend_operators.c にあります。この関数で、比較される値の実行時型に応じた処理が実行されます。今回のケース (1.0 と "0x1") では、最初は switch 文のどの case にも当てはまらず default 節に流れ、zendi_convert_scalar_to_number 関数が呼び出されます。この関数で == の右辺にあった文字列が数値に変換され*2、一回 while ループを回った後に IS_DOUBLE と IS_LONG の比較として処理されます。

ZEND_API int compare_function(zval *result, zval *op1, zval *op2 TSRMLS_DC) /* {{{ */
{
    ...
	while (1) {
		switch (TYPE_PAIR(Z_TYPE_P(op1), Z_TYPE_P(op2))) {
                    ...
					} else {
						zendi_convert_scalar_to_number(op1, op1_copy, result);
						zendi_convert_scalar_to_number(op2, op2_copy, result);
						converted = 1;
					}
    ...

php-src/zend_operators.c at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub

zendi_convert_scalar_to_number 関数は Zend/operators.c にマクロとして定義されています。ここでも値の実行時型に応じた処理が行われますが、文字列の場合は is_numeric_string 関数を呼び出して文字列を数値に変換します。なお、上のコードでは == の左辺と右辺の両方を変換するように書かれていますが、数値型の場合は、このマクロの switch 文でどの case にも当てはまらないため何の処理も行われず、元の値がそのまま利用される仕掛けになっています。

/* {{{ zendi_convert_scalar_to_number */
#define zendi_convert_scalar_to_number(op, holder, result)			\
    ...
		switch (Z_TYPE_P(op)) {										\
			case IS_STRING:											\
				{													\
					if ((Z_TYPE_INFO(holder)=is_numeric_string(Z_STRVAL_P(op), Z_STRLEN_P(op), &Z_LVAL(holder), &Z_DVAL(holder), 1)) == 0) {	\
						ZVAL_LONG(&(holder), 0);							\
					}														\
					(op) = &(holder);										\
					break;													\
				}															\
    ...

php-src/zend_operators.c at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub

is_numeric_string 関数は Zend/zend_operators.h にあります。この関数から is_numeric_string_ex 関数を経由して、Zend/zend_operators.c の _is_numeric_string_ex 関数を呼び出します。
php-src/zend_operators.h at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub
php-src/zend_operators.h at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub

_is_numeric_string_ex 関数が、文字列から数値への変換処理の実体です。整数と見なせる文字列ならば lval に、浮動小数点数と見なせる文字列ならば dval に変換結果を書き込みます。戻り値は IS_LONG, IS_DOUBLE, または 0 (整数でも浮動小数点数でもない場合) になります。文字列の先頭が "0x" または "0X" のときは基数を 16 にするという処理も、この関数に実装されています。

ZEND_API zend_uchar _is_numeric_string_ex(const char *str, size_t length, zend_long *lval, double *dval, int allow_errors, int *oflow_info)
{
    ...
	if (ZEND_IS_DIGIT(*ptr)) {
		/* Handle hex numbers
		 * str is used instead of ptr to disallow signs and keep old behavior */
		if (length > 2 && *str == '0' && (str[1] == 'x' || str[1] == 'X')) {
			base = 16;
			ptr += 2;
		}
    ....

php-src/zend_operators.c at 1f432c85b0431d5d4182cb8e80bac5df6c4df632 · php/php-src · GitHub

HHVM の実装

次に、HHVM での実装を追ってみます。HHVM の場合は、== 演算子は Eq 命令に変換され、hphp/runtime/vm/bytecode.cpp の以下の箇所で処理されます*3

OPTBLD_INLINE void ExecutionContext::iopEq(IOP_ARGS) {
  implCellBinOpBool(IOP_PASS_ARGS, [&] (Cell c1, Cell c2) {
    return cellEqual(c1, c2);
  });
}

hhvm/bytecode.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

cellEqual 関数は hphp/runtime/base/tv-comparisons.cpp に定義されています。これは、同じファイルに定義されている cellRelOp 関数テンプレートを呼び出すだけの小さな関数です。

bool cellEqual(Cell c1, Cell c2) {
  return cellRelOp(Eq(), c1, c2);
}

hhvm/tv-comparisons.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

cellRelOp 関数テンプレートは次のような実装です。演算子の右辺の実行時型に応じて、オーバーロードされた関数を呼び出します。今回のケースでは、== の右辺が文字列型なので、cellRelOp(Op, Cell, const StringData*) が呼び出されることになります。

template<class Op>
bool cellRelOp(Op op, Cell c1, Cell c2) {
  assert(cellIsPlausible(c1));
  assert(cellIsPlausible(c2));

  switch (c2.m_type) {
  ...
  case KindOfString:       return cellRelOp(op, c1, c2.m_data.pstr);
  ...
    break;
  }
  not_reached();
}

hhvm/tv-comparisons.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

cellRelOp(Op, Cell, const StringData*) は以下のように実装されています。ここで、演算子の左辺の実行時型に応じて比較処理が実行されます。== の左辺が整数のときは stringToNumeric 関数を呼び出して右辺の値を変換し、変換結果が整数、浮動小数点数、それ以外の場合に分けて処理していますが、== の左辺が浮動小数点数のときは、右辺を toDouble 関数で変換して比較しています。ここに、問題の原因がありそうです。

template<class Op>
bool cellRelOp(Op op, Cell cell, const StringData* val) {
  assert(cellIsPlausible(cell));

  switch (cell.m_type) {
    case KindOfUninit:
    case KindOfNull:
      return op(staticEmptyString(), val);

    case KindOfInt64: {
      auto const num = stringToNumeric(val);
      return num.m_type == KindOfInt64  ? op(cell.m_data.num, num.m_data.num) :
             num.m_type == KindOfDouble ? op(cell.m_data.num, num.m_data.dbl) :
             op(cell.m_data.num, 0);
    }
    case KindOfBoolean:
      return op(!!cell.m_data.num, toBoolean(val));

    case KindOfDouble:
      return op(cell.m_data.dbl, val->toDouble());

    ...
}

hhvm/tv-comparisons.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

まず stringToNumeric 関数の実装を見てみます。この関数は hphp/runtime/base/tv-conversions-inl.h に定義されています。StringData クラスの isNumericWithVal 関数を呼び出して文字列を数値に変換します。

inline TypedNum stringToNumeric(const StringData* sd) {
  int64_t ival;
  double dval;
  auto const dt = sd->isNumericWithVal(ival, dval, true /* allow_errors */);
  return dt == KindOfInt64 ? make_tv<KindOfInt64>(ival) :
         dt == KindOfDouble ? make_tv<KindOfDouble>(dval) :
         make_tv<KindOfInt64>(0);
}

hhvm/tv-conversions-inl.h at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

isNumericWithVal 関数は hphp/runtime/base/string-data.cpp にあります。変換処理の本質部分は is_numeric_string 関数を呼び出します。数値に変換できない文字列の場合には、その情報を m_hash にメモしておくことで、is_numeric_string 関数を不必要に何度も呼び出すことを避けているようです。

DataType StringData::isNumericWithVal(int64_t &lval, double &dval,
                                      int allow_errors, int* overflow) const {
  if (m_hash < 0) return KindOfNull;
  DataType ret = KindOfNull;
  StringSlice s = slice();
  if (s.len) {
    ret = is_numeric_string(s.ptr, s.len, &lval, &dval, allow_errors, overflow);
    if (ret == KindOfNull && !isShared() && allow_errors) {
      m_hash |= STRHASH_MSB;
    }
  }
  return ret;
}

hhvm/string-data.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

is_numeric_string 関数は hphp/runtime/base/zend-functions.cpp にあります。この関数は PHP のものを流用して実装されているようです。
hhvm/zend-functions.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

次に、問題となっている浮動小数点数と文字列の比較のケースを追ってみます。このケースでは、StringData クラスの toDouble 関数を呼び出して文字列を浮動小数点数に変換します。toDouble 関数は以下のように実装されています。zend_strtod 関数を呼び出して文字列を変換します。

double StringData::toDouble() const {
  StringSlice s = slice();
  if (s.len) return zend_strtod(s.ptr, nullptr);
  return 0;
}

hhvm/string-data.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

zend_strtod 関数は hphp/runtime/base/zend-strtod.cpp にあります。is_numeric_string 関数と同様に、これも PHP から流用した実装になっているようです。zend_strtod 関数は基数が 10 であることを前提とした変換なので、"0x1" は "x" の手前までが変換されて 0 になります。このため、HHVM では浮動小数点数と 16 進数値文字列が正しく (文字列を 16 進数の数値と解釈する形で) 比較されません。
hhvm/zend-strtod.cpp at 7904234440834cd5660e0dd374e5916e3711bac4 · facebook/hhvm · GitHub

HHVM の挙動を変更する

さて、HHVM の実装の流れがわかったので、ソースコードを書き換えてこの問題を修正してみます。浮動小数点数と文字列を比較するケースでも、整数と文字列を比較するケース同様に stringToNumeric 関数で変換するように変更すればよさそうです*4

以下が修正部分の diff です。前半の修正は、左辺が文字列で右辺が浮動小数点数の場合、後半は左辺が浮動小数点数で右辺が文字列の場合に対応します。

$ diff -u tv-comparisons.cpp{.orig,}
--- tv-comparisons.cpp.orig     2014-11-13 22:20:29.715213866 +0900
+++ tv-comparisons.cpp  2014-11-16 16:00:19.570658090 +0900
@@ -114,8 +114,12 @@
       return op(cell.m_data.dbl, val);

     case KindOfStaticString:
-    case KindOfString:
-      return op(toDouble(cell.m_data.pstr), val);
+    case KindOfString: {
+      auto const num = stringToNumeric(cell.m_data.pstr);
+      return num.m_type == KindOfInt64  ? op(static_cast<double>(num.m_data.num), val) :
+             num.m_type == KindOfDouble ? op(num.m_data.dbl, val) :
+             op(0, val);
+    }

     case KindOfArray:
       return op(true, false);
@@ -153,8 +157,12 @@
     case KindOfBoolean:
       return op(!!cell.m_data.num, toBoolean(val));

-    case KindOfDouble:
-      return op(cell.m_data.dbl, val->toDouble());
+    case KindOfDouble: {
+      auto const num = stringToNumeric(val);
+      return num.m_type == KindOfInt64  ? op(cell.m_data.dbl, static_cast<double>(num.m_data.num)) :
+             num.m_type == KindOfDouble ? op(cell.m_data.dbl, num.m_data.dbl) :
+             op(cell.m_data.dbl, 0);
+    }

     case KindOfStaticString:
     case KindOfString:

このように変更したコードをビルドして実行してみると、たしかに正しく比較されるようになりました。

$ cat test.php
<?php
var_dump(1.0 == "0x1");
var_dump(0.0 == "0x1");
var_dump("0x1" == 1.0);
var_dump("0x1" == 0.0);

$ ~/src/hhvm/hphp/hhvm/hhvm test.php
bool(true)
bool(false)
bool(true)
bool(false)

*1:ちなみに、=== の場合は ZEND_IS_IDENTICAL になります。

*2:もちろん、元の文字列そのものが変更されるわけではなく、数値型の ZVAL 構造体が新たに作成されます。

*3:=== 演算子の場合は Same 命令になります。

*4:あくまでも、今回指摘されているケースを PHP と同じ挙動にすることだけを意図したコード変更です。この変更が他にどういう影響を与えるかなどは検討していません。