Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix JIT for overridden properties with added hooks #17395

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

iluuu1994
Copy link
Member

@iluuu1994 iluuu1994 commented Jan 7, 2025

Fixes GH-17376

The change in zend_build_properties_info_table() is needed to keep overridden property infos up-to-date in child classes that only override properties but add no new ones. The behavior is currently inconsistent, which is itself probably a bug.

It would be faster to only compare the class itself instead of the property info, but being pessimistic may lead to picking the slow path too often, which would be much more expensive. @dstogov Maybe you have some better suggestion.

@iluuu1994
Copy link
Member Author

Some code in that function breaks that I don't yet fully understand. I'll need to take a closer look tomorrow.

@iluuu1994 iluuu1994 force-pushed the gh-17376 branch 4 times, most recently from 2f30e3d to e1237ca Compare January 8, 2025 15:42
Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understood this fix adds additional checks for each "known" non-final property read.

At first the fix looks incomplete. It doesn't take into account other property accessors. ASSIGN_PROP, ASSIGN_PROP_OP, PRE/POST_INC/DEC_PROP, ISSET_PROP (may be others).

At second this is a big overhead. 9 assembler instructions instead of 5 (+3 additional loads) for almost every fast-path property access. Tracing JIT with Symfony Demo and this incomplete fix starts to generate 1.5% more code and becomes 0.2% slower. I can't express my attitude in normative language...

For tracing JIT you may try to fix the problem using class guards. At least they are a bit cheaper (2 instructions, 1 load) and may be checked once for SSA variable.

I don't see how the fix may be improved for function JIT.

@iluuu1994
Copy link
Member Author

iluuu1994 commented Jan 9, 2025

I'm aware the fix is incomplete, but I wanted to wait for feedback first. For tracing JIT, in case of compile-time unknown property infos, we use the property info discovered during tracing. This avoids the additional check, as we set instanceof to 0. We could simplify this check further by checking for the class instead, but accessing properties from subclasses is common, so this would result in more slow path executions.

Can you clarify on your guard suggestion? I don't understand the difference to the check in this PR.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

I'm aware the fix is incomplete, but I wanted to wait for feedback first. For tracing JIT, in case of compile-time unknown property infos, we use the property info discovered during tracing. This avoids the additional check, as we set instanceof to 0.

The most usual case (accessing property of $this) didn't require class guard. The code from micro_bench.php.

class Foo {
    public $b = 0;
    function read_prop($n) {
        for ($i = 0; $i < $n; ++$i) {
            $x = $this->b;
        }
    }
}

The fix adds check for property info on each loop iteration. Now take a look into something like PhpParser\\ParserAbstract::doParse() that performs hundreds properties accesses in a loop. A single class guard at start of the trace, may replace all the separate property guards.

We could simplify this check further by checking for the class instead, but accessing properties from subclasses is common, so this would result in more slow path executions.

You are right. This solution is not ideal. The class guards failure may lead to recording and compiling identical traces for different subclasses that access property declared in a base class.

Can you clarify on your guard suggestion? I don't understand the difference to the check in this PR.

I hope, I explained this above.

What was the reason in ability to add hooks in subclasses for a regular property declared in the parent?
I suppose, this shouldn't be allowed. You should be able to change the hooks, but not converting regular and "hooked" properties back and force.

@iluuu1994
Copy link
Member Author

iluuu1994 commented Jan 9, 2025

A single class guard at start of the trace, may replace all the separate property guards.

I see, thank you for the explanation. Another thing we could do is add a "property hooks" guard, i.e. check that the traced object doesn't have any property hooks (ce->num_hooked_props == 0). This would lead to bigger slowdowns for objects with any hooked properties, as they would fall back to the VM, but it would avoid most of the slowdown for normal code for now. WDYT?

What was the reason in ability to add hooks in subclasses for a regular property declared in the parent?

It was a fundamental design decision to make plain properties completely compatible with hooks. Some languages only allow adding accessors to child classes for public $prop { get; set; } parent properties, and we wanted to avoid fragmenting the whole ecosystem into plain props and { get; set; } props that specify these empty hooks "just in case".

prop_info_ref = ir_LOAD_A(ir_ADD_OFFSET(prop_info_ref, prop_info_offset));
ir_ref is_same_prop_info = ir_EQ(prop_info_ref, ir_CONST_ADDR(prop_info));
if (JIT_G(trigger) == ZEND_JIT_ON_HOT_TRACE) {
int32_t exit_point = zend_jit_trace_get_exit_point(opline, ZEND_JIT_EXIT_TO_VM);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ZEND_JIT_EXIT_TO_VM looks too aggressive. Why can't we record another trace with slow-path JIT-ed code.
To be more accurate instead of checking for the same prop_info, we may check prop_info->hooks == NULL.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

A single class guard at start of the trace, may replace all the separate property guards.

I see, thank you for the explanation. Another thing we could do is add a "property hooks" guard, i.e. check that the traced object doesn't have any property hooks (ce->num_hooked_props == 0). This would lead to bigger slowdowns for objects with any hooked properties, as they would fall back to the VM, but it would avoid most of the slowdown for normal code for now. WDYT?

I don't see why "unexpectedly appeared" property hooks should lead to exit to VM. They should be handled by side traces. So I don't see how "property hooks" would really help (may be I didn't understand yet).

What was the reason in ability to add hooks in subclasses for a regular property declared in the parent?

It was a fundamental design decision to make plain properties completely compatible with hooks. Some languages only allow adding accessors to child classes for public $prop { get; set; } parent properties, and we wanted to avoid fragmenting the whole ecosystem into plain props and { get; set; } props that specify these empty hooks "just in case".

This is a yet anther shoot in the leg. The languages designers made that restriction on purpose.

@iluuu1994
Copy link
Member Author

iluuu1994 commented Jan 9, 2025

I don't see why "unexpectedly appeared" property hooks should lead to exit to VM. They should be handled by side traces.

There's no reason other than that I just don't understand it well, I don't know how this would be implemented.

So I don't see how "property hooks" would really help (may be I didn't understand yet).

My idea was basically to create a new type of guard that checks if the runtime object declares any new property hooks that the compile time ce did not (i.e. obj->ce->num_hooked_props != comp_time_ce->num_hooked_props). As you say, this could also be done once per trace, rather than for each property access, and would not prevent traces from being reused in other subclasses. But that's with a limited understanding, so it might not make sense.

The languages designers made that restriction on purpose.

That's an assumption. Newer languages do not differentiate between properties and fields, e.g. Swift or Kotlin. C# does it, but it's also historic, because fields were a thing before properties. It also has ABI implications there, which do not apply here.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

Newer languages do not differentiate between properties and fields, e.g. Swift or Kotlin.

Are you sure they allow "conversion" of field to property, may be only overriding?

If this is not true you should take a look into Swift/Kotlin implementations (find related papers or speak to their experts) to understand how they solve the problem - "It's not possible to simple read object filed if some child class may convert it to property".

One more idea how this might be implemented in PHP. PHP always performs type check when reads a filed (this is stupid but even typed field may be unset). In case we might set type of filed (that is converted to property) to be IS_UNDEF, it would start to work out of the box. I suspect, this solution won't work out of the box, but may be you'll find something on top of it.

@iluuu1994
Copy link
Member Author

I will think about more solutions, but I will also need to take some time just understanding traces and guards better. I don't currently understand the difference between a side-exit and falling back to the interpreter, in other contexts I've seen those two used interchangeably.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

I will think about more solutions, but I will also need to take some time just understanding traces and guards better. I don't currently understand the difference between a side-exit and falling back to the interpreter, in other contexts I've seen those two used interchangeably.

Initially any failed guard leads to transferring control to VM interpreter.
With ZEND_JIT_EXIT_TO_VM repeatable failures of the same guard will always fall-back to VM.
Without ZEND_JIT_EXIT_TO_VM at some point (after opcache.jit_hot_side_exit) we will record and compile a side trace and link the failed guard to the new side trace start, so on the next failure of the same guard we will directly jump to the new side trace.

@iluuu1994
Copy link
Member Author

iluuu1994 commented Jan 9, 2025

Are you sure they allow "conversion" of field to property, may be only overriding?

Just to clarify: Yes, they do allow "virtual" properties, they don't just shadow the properties, if that's what you meant. E.g. see Kotlin here https://pl.kotl.in/iXAa7j5ID. I'm certainly not an expert in these languages but I suspect that they can access fields directly when the class hierarchy is known, thus allowing to omit a call to the accessor function. Of course, another great benefit there is that they are final by default. At least Kotlin, I don't remember Swift.

One more idea how this might be implemented in PHP. PHP always performs type check when reads a filed (this is stupid but even typed field may be unset). In case we might set type of filed (that is converted to property) to be IS_UNDEF, it would start to work out of the box. I suspect, this solution won't work out of the box, but may be you'll find something on top of it.

I have also thought of this. We could think about allocating another slot in the object, but this still won't help if the assignment in the JIT happens to the original property slot. So at least this part would have to be avoided in some way.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

Are you sure they allow "conversion" of field to property, may be only overriding?

Just to clarify: Yes, they do allow "virtual" properties, they don't just shadow the properties, if that's what you meant. E.g. see Kotlin here https://pl.kotl.in/iXAa7j5ID. I'm certainly not an expert in these languages but I suspect that they can access fields directly when the class hierarchy is known, thus allowing to omit a call to the accessor function. Of course, another great benefit there is that they are final by default. At least Kotlin, I don't remember Swift.

I see. A bit better example https://pl.kotl.in/Dbkz5gvge and the generated JS

  function printProp(a) {
    print(a.x_1);
    print(a.get_y_1mhr68_k$());
    print(a.get_prop_wosl9o_k$());
    print(a.foo_26di_k$());
    print('\n');
  }

open properties are implemented through the virtual getter, final fields are read directly but can't be overridden. So open is a "syntactic sugar" for public $prop { get; set; }.

Unfortunately in PHP properties are not final by default.

@iluuu1994
Copy link
Member Author

For context, Swift does not require open. https://swiftfiddle.com/aie6px6xnvdifnosh56xb3kpt4 But they can likely discover the class hierarchy during compilation, I believe their smallest compilation unit is an entire module.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

For context, Swift does not require open. https://swiftfiddle.com/aie6px6xnvdifnosh56xb3kpt4 But they can likely discover the class hierarchy during compilation, I believe their smallest compilation unit is an entire module.

This site doesn't work for me. Try to analyse the generated code. When it reads fields directly? When access through getters?

@iluuu1994
Copy link
Member Author

iluuu1994 commented Jan 9, 2025

@dstogov I used https://godbolt.org/noscript/swift with the -emit-sil flag and the script below. It's unfortunately very unradable. It seems vars emit class_method ... #A.prop1!getter instructions, while let does a direct access with ref_element_addr. Anyway, I'm not sure this helps us too much...

Script

import Foundation

class A {
  var prop: Int = 1
  let prop2: Int
  var prop3: Int

  init(prop2: Int, prop3: Int) {
    self.prop2 = prop2
    self.prop3 = prop3
  }
}

class B: A {
  override var prop: Int {
    get {
      return super.prop * 2
    }
    set {
      super.prop = newValue
    }
  }
}

func print1(a: A) {
  print(a.prop)
}

func print2(a: A) {
  print(a.prop2)
}

func print3(a: A) {
  print(a.prop3)
}

print1(a: A(prop2: 42, prop3: 43))
print2(a: B(prop2: 42, prop3: 43))
print3(a: B(prop2: 42, prop3: 43))

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

It seems like swift always reads properties/fields through virtual getters. (I tried your old example with -O -S). printProp() is compiled into the same asm even if I remove property overriding in B.

output.printProp(a: output.A) -> ():
        push    r13
        push    rbx
        push    rax
        mov     r13, rdi ; keep "a" in %r13                                
        lea     rdi, [rip + (demangling cache variable for type metadata for Swift._ContiguousArrayStorage<Any>)]
        call    __swift_instantiateConcreteTypeFromMangledName
        mov     esi, 64
        mov     edx, 7
        mov     rdi, rax
        call    swift_allocObject@PLT
        mov     rbx, rax
        mov     qword ptr [rax + 16], 1
        mov     qword ptr [rax + 24], 2
        mov     rax, qword ptr [r13] ; load VMT into %rax
        call    qword ptr [rax + 64] ; call some virtual property getter
        mov     rcx, qword ptr [rip + ($sSiN)@GOTPCREL]
        mov     qword ptr [rbx + 56], rcx
        mov     qword ptr [rbx + 32], rax
        movabs  rdx, -2233785415175766016
        mov     esi, 32
        mov     ecx, 10
        mov     rdi, rbx
        mov     r8, rdx
        call    ($ss5print_9separator10terminatoryypd_S2StF)@PLT
        mov     rdi, rbx
        add     rsp, 8
        pop     rbx
        pop     r13
        jmp     swift_release@PLT

@iluuu1994
Copy link
Member Author

iluuu1994 commented Jan 9, 2025

I don't really understand this assembly, but the print2 assembly has an additional call instruction compared to print3. So, there seems to be a difference between var and let (let being the equivalent of const), as mentioned. let properties cannot have accessors, so that's why it's safe for them to skip the getter in this csae.

@iluuu1994
Copy link
Member Author

Oh, and making the properties fileprivate also does it, because then we know there can't be some other child class in a different compilation unit overriding the property.

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

I don't really understand this assembly, but the print2 assembly has an additional call instruction compared to print3

You messed print2 and print3. (print3 has an additional call)
print1 and print3 are almost equivalent (they call getters at different VMT offset), print2 reads property directly by offset (cheaper code).

@dstogov
Copy link
Member

dstogov commented Jan 9, 2025

Conclusion: Kotlin uses getters for "open" properties, this is not a big problem as the properties are final by default. Swift always use getters for "var", but optimizer may inline them in some cases. PHP with non-final properties has to check for hooks on each access (VM optimizes this caching HOOKED_PROPERTY_OFFSET), but efficient compilation with hard-coded property offsets becomes a problem. This may be partially improved by additional guards in tracing JIT, but not in function JIT. I would consider disabling conversion of fields into property by default, and allowing this only for fields with some attribute.

@dstogov dstogov closed this Jan 9, 2025
@dstogov dstogov reopened this Jan 9, 2025
Copy link
Member Author

@iluuu1994 iluuu1994 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dstogov What do you think about this solution for now? Unfortunately, I can still see an instruction increase of 1.6% for Symfony Demo, but I could not measure any difference in performance.

/* Child did not add any new properties, we are done */
if (ce->default_properties_count == ce->parent->default_properties_count) {
return;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move this to a separate PR, but IMO this should still be fixed.

@@ -14435,7 +14485,7 @@ static int zend_jit_fetch_obj(zend_jit_ctx *jit,
SET_STACK_REG(JIT_G(current_frame)->stack, EX_VAR_TO_NUM(opline->op1.var), ZREG_NONE);
}

if (JIT_G(trigger) != ZEND_JIT_ON_HOT_TRACE || !prop_info) {
if (slow_inputs) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could technically be reverted, but seems more logical.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[BUG] Property hooks + enabled JIT causes unexpected results (segfault for JIT function)
3 participants