0xDEADBEEF

RSS odkazy
««« »»»

PHP by byl lepší jazyk, kdyby byl napsaný v Rustu

Ne nutně v Rustu, ale v jakémkoli jazyce, který má dobré nástroje pro metaprogramování.

Jeden z problémů, který stojí v cestě zrychlování PHP, je fakt, že interní funkce jsou napsané v C. Ty pro JIT představují neprůhledný blob a JIT nemůže optimalizovat napříč voláním do nich. Může znát statické typy proměnných a mít je v registrech, ale jakmile narazí na interní funkci, musí tohle všechno zahodit, zrekonstruovat stav vyžadovaný interpretrem, alokovat stack frame (opkód INIT_FCALL, funkce _zend_vm_stack_push_call_frame), proměnné zkopírovat z registrů a zrekonstruovat zvaly, které úspěšně odoptimalizoval (SEND_VAL) a pak zavolat samotnou interní funkci (DO_ICALL, proměnná _zend_function.internal_function.handler).

Funkce jako první začne parsovat argumenty, aby ověřila jejich typy a nastrká je zas do registrů a tohle všechno je zbytečná práce a promrhaný čas. Není to moc, jen pár nanosekund, ale je to pár nanosekund pro každé volání každé interní funkce a to se rychle nastřádá.

Definitivní způsob jak odstranit tuhle překážku je přepsat interní funkce z C do PHP nebo do meta-jazyka (v duchu RPythonu), aby skrz něj JIT viděl a mohl je optimalizovat v kontextu jako jakýkoli jiný kus PHP kódu.

To je ale obrovský krok, který zasáhne velkou část tří milionů řádek PHP zdrojáků.

Napůl cesty nás může dostat, když zredukujeme cenu volání. Bylo by super, kdyby v případě, kdy si je JIT naprosto jistý, že typy argumentů pasují, funkci zavolal přímo a obešel taneček, který je nutný jen pro interpretr. Funkce umíme volat přímo, System V ABI je věc, která existuje v našem vesmíru. Problém je jak přinutit PHP, aby tak činilo, pokud možno s nejmenší možnou námahou.

A tady do hry vstupuje ta myšlenka, že kdyby PHP bylo napsané v expresivnějším jazyce, tohle by nebyl problém. Já to vím nejlépe, protože aniž bych to plánoval, jsem tento problém vyřešil.

Nejdřív ale musím vysvětlit jak v PHP vypadá interní funkce.

Představte si, že mám funkci str_popcount, která spočítá počet nastavených bitů ve stringu a chci ji exportovat do PHP. Rozšíření je (v duchu monstrozity PHP) nepřehledná změť mnoha souborů, některých automaticky generovaných skriptem phpize mezi nimiž se můj užitečný kód zcela ztrácí.

Tělo funkce může vypadat takhle:

PHP_FUNCTION(str_popcount)
{
  zend_string *data;

  ZEND_PARSE_PARAMETERS_START(1, 1)
    Z_PARAM_STR(data)
  ZEND_PARSE_PARAMETERS_END();

  zend_long result = str_popcount(ZSTR_VAL(data), ZSTR_LEN(data));

  RETVAL_LONG(result);
}

Deklaruje proměnné, naparsuje parametry, provede samotný výpočet a pak vrátí výsledek. Parsování parametrů bych se rád zbavil.

Může to vypadat přehledně, ale to jen proto, že makra skrývají děs PHP zdrojáků. Po expanzi funkce vypadá monstrózně, jako zlý sen, který nikdy neskončí.

void zif_str_popcount(zend_execute_data *execute_data, zval *return_value) {
  zend_string *data;

  do {
    const int _flags = (0);
    uint32_t _min_num_args = (1);
    uint32_t _max_num_args = (uint32_t) (1);
    uint32_t _num_args = (execute_data)->This.u2.num_args
    uint32_t _i = 0;
    zval *_real_arg, *_arg = NULL;
    zend_expected_type _expected_type = Z_EXPECTED_LONG;
    char *_error = NULL;
    bool _dummy = 0;
    bool _optional = 0;
    int _error_code = ZPP_ERROR_OK;
    ((void)_i);
    ((void)_real_arg);
    ((void)_arg);
    ((void)_expected_type);
    ((void)_error);
    ((void)_optional);
    ((void)_dummy);

    do {
      if (UNEXPECTED(_num_args < _min_num_args) ||
          UNEXPECTED(_num_args > _max_num_args)) {
        if (!(_flags & ZEND_PARSE_PARAMS_QUIET)) {
          zend_wrong_parameters_count_error(_min_num_args, _max_num_args);
        }
        _error_code = ZPP_ERROR_FAILURE;
        break;
      }
      _real_arg = (((zval*)(execute_data)) + (((int)((sizeof(zend_execute_data) + sizeof(zval) - 1) / sizeof(zval))) + ((int)(((int)(0)) - 1))))


    ++_i;
    ZEND_ASSERT(_i <= _min_num_args || _optional==1);
    ZEND_ASSERT(_i >  _min_num_args || _optional==0);
    if (_optional) {
      if (UNEXPECTED(_i >_num_args)) break;
    }
    _real_arg++;
    _arg = _real_arg;
    if (0) {
      if (EXPECTED(Z_ISREF_P(_arg))) {
        _arg = Z_REFVAL_P(_arg);
      }
    }
    if (0) {
      do {
        zval *_zv = (_arg);
        ZEND_ASSERT(Z_TYPE_P(_zv) != IS_REFERENCE);
        if (Z_TYPE_P(_zv) == IS_ARRAY) {
          SEPARATE_ARRAY(_zv);
        }
      } while (0)
    }

    if (UNEXPECTED(!zend_parse_arg_str(_arg, &dest, 0, _i))) {
      _expected_type = 0 ? Z_EXPECTED_STRING_OR_NULL : Z_EXPECTED_STRING;
      _error_code = ZPP_ERROR_WRONG_ARG;
      break;
    }

      ZEND_ASSERT(_i == _max_num_args || _max_num_args == (uint32_t) -1);
    } while (0);
    if (UNEXPECTED(_error_code != ZPP_ERROR_OK)) {
      if (!(_flags & ZEND_PARSE_PARAMS_QUIET)) {
        zend_wrong_parameter_error(_error_code, _i, _error, _expected_type, _arg);
      }
      return;
    }
  } while (0);


  zend_long result = str_popcount((zval).value.str->val, (zval).value.str->len);

  do {
    zval *__z = (return_value);
    (*__z).value.lval = result;
    (*zval).u1.type_info = IS_LONG;
  } while (0);
}

Tenle text se pošle kompilátoru, který po zpropagování konstant většinu mrtvého kódu smaže. Zůstane jen nepříliš dlouhá sekvence instrukcí.

S tímhle se ale nedá nic moc dělat. C neumožňuje čistě bez přepisování všech funkcí separovat část parsující argumenty od užitečné práce funkce. Preprocesování prostou náhradou textu je přehnaně hrubý nástroj.

Potřebovali bychom se podívat na typy a podle nich generovat kód pro dvě verze této funkce – jedné takové jak je napsaná výše a druhé, které přeskočí parsování argumentů.

Před nějakou dobou jsem v jazyce D napsal nástroj pro snazší tvorbu PHP rozšíření. Z výkonových důvodů se mi hodilo exportovat několik nativních funkcí, ale nechtěl jsem zase projít utrpením s tvorbou rozšíření v C, jak to PHP gang zamýšlel. Jednou jsem to zkusil a dodnes mě to budí ze spaní. Ukázalo se, že vytvořit nový nástroj zcela od nuly je snazší než se prokousávat existující mizérií. Bylo třeba trochu reverzování, protože, jak je pro PHP obvyklé, mechanismus nahrávání rozšíření není nikde popsaný a detaily jsou zahrabané pod nekonečnými vrstvami maker.

Takhle vypadá kompletní rozšíření, co vystaví funkci str_popcount:

import phpmod;

ModuleEntry mod = {
  name: "popcount",
  version_: "1",
  functions: [
    func!(str_popcount, "str_popcount"),
    zend_function_entry(),
  ],
};

extern(C) ModuleEntry* get_module() {
  return &mod;
}

long str_popcount(const(ubyte)[] str) {
  import core.bitop;
  if (str.length == 0) return 0;
  long bits = 0;
  auto p   = str.ptr;
  auto end = str.ptr + str.length;
  for (; p < end - 7; p += 8) bits += popcnt(*cast(ulong*)p);
  for (; p < end;     p += 1) bits += popcnt(*p);
  return bits;
}

A když říkám kompletní, myslím jako že tohle je všechno: jeden soubor, co importuje modul phpmod, také jeden soubor. Dokonce jsem přidal implementaci samotné funkce, kterou jsem z PHP ukázky pro přehlednost vyhodil.

Všimněte si, že nikde nezmiňuji PHP typy. Argumentem není zend_string, ale pole bajtů. Funkce nenastavuje návratovou hodnotu přes RETVAL_LONG, ale jednoduše ji, jako v civilizovaném světě, vrátí. Mechanismus parsování argumentů a kód pro kontakt s PHP rozhraním je během kompilace na základě typů automaticky generovaný v templatované funkci func. Dokonce, když v nativním kódu vyhodím výjimku, ta je propagovaná napříč rozhraním a můžu ji odchytit na straně PHP.

Když interní funkce píšu přes tenhle mechanismus, nemusím generovat jen jednu funkci pro PHP ABI, ale i druhou nativní, co neparsuje argumenty, ale bere je, jak ji přijdou v registrech přesně podle diktátu System V ABI. Pointer na ní se uloží někam do internal_function a JIT ji může použít pro volání nativní rychlostí, když ví, že typy pasují. To všechno zcela automaticky beze změny jediného bajtu jediné interní funkce.

Takže ano, řekl bych, že by aspoň v některých ohledech PHP benefitovalo z lepšího jazyka, možná Rustu nebo něčeho jiného, co na frontě metaprogramování nabízí víc než textová makra.

Navíc kód by určitě vypadal lépe.


Dodatek: Na první pohled může vypadat, že buď dojde ke zpomalení nebo k duplikaci kódu. V jenom případě generovaná funkce parsující argumenty volá funkce s užitečnou prací. To si vyžádá call/ret pár. V druhém je tělo i wrapper inlinovány dohromady a kód je duplikován.

Nemusí tomu tak být.

Když mám:

int64_t __attribute__ ((noinline)) body(int64_t a) {
  return a;
}

int64_t parse_arguments(void* a) {
  int64_t i = *(int64_t*) a;
  if (i < 0) return 0;
  return body(i);
}

GCC vygeneruje následující binárku:

<body>:
    1100:	48 89 f8             	mov    %rdi,%rax
    1103:	c3                   	ret

<parse_arguments>:
    1110:	48 8b 3f             	mov    (%rdi),%rdi
    1113:	48 85 ff             	test   %rdi,%rdi
    1116:	78 08                	js     1120 <parse_arguments+0x10>
    1118:	eb e6                	jmp    1100 <body>
    111a:	66 0f 1f 44 00 00    	nopw   0x0(%rax,%rax,1)
    1120:	31 c0                	xor    %eax,%eax
    1122:	c3                   	ret

parse_arguments přímo skočí do funkce body instrukcí jmpret na konci body se vrátí z funkce parse_arguments. Cena bude nižší, jeden nepodmíněný skok a fakt, že horký kód neleží na přímé cestě.

píše k47 (@kaja47, k47)