Skip to content

JIT miscompiles untyped function parameters as zero after RECV elision #32

Description

@lukasojd

Summary

The whole-function JIT can compile functions with untyped integer arguments, but their parameter CV slots are initialized to zero instead of receiving the runtime arguments.

This produces silently incorrect results.

Tested at commit 67ef61e68048a2a508cae3bb0834cfe69863fe46 on Linux x86_64 with Rust 1.96.0.

Current JIT build blocker

At this commit, cargo build --release --features jit first fails because jit_loop_hook() uses ctx without receiving it.

For runtime testing, I applied only the mechanical wiring fix:

-pub(crate) fn jit_loop_hook(&mut self, idx: usize, header: usize, end: usize)
+pub(crate) fn jit_loop_hook(&mut self, ctx: &mut RunCtx, idx: usize, header: usize, end: usize)

and:

-vm.jit_loop_hook(idx, cur, end);
+vm.jit_loop_hook(ctx, idx, cur, end);

No JIT logic was otherwise changed.

Reproducer

<?php

function add1($x) {
    return $x + 1;
}

function double($x) {
    return $x + $x;
}

function combine($a, $b) {
    return add1($a) + double($b);
}

$sum = 0;

for ($i = 0; $i < 5_000_000; $i++) {
    $sum += combine($i, $i + 1);
}

echo $sum, PHP_EOL;

Build and run:

cargo build --release --features jit
./target/release/rphp reproducer.php
./target/release/rphp --jit reproducer.php

Result

Without JIT:

37500007500000

With JIT:

5000000

Expected:

37500007500000

Suspected root cause

The compiler elides RECV for required untyped by-value parameters because the interpreter receives arguments directly in the callee CV slots.

However, the whole-function JIT uses a different convention:

  1. is_int_only() accepts the resulting op array as JIT-compatible.
  2. translate() initializes every JIT slot to zero.
  3. Runtime arguments are loaded into CV slots only while translating an Opcode::Recv.
  4. Because RECV was elided, the generated kernel never reads the corresponding values from args_ptr.

As a result, leaf functions behave as if every untyped parameter were zero:

add1($x)   => add1(0)   => 1
double($x) => double(0) => 0

Each combine() call therefore returns 1, explaining the observed total of exactly 5000000.

The existing parameter JIT test uses a typed parameter:

function kern(int $n): int

That form retains RECV, so it does not exercise the compiler/JIT mismatch for untyped parameters.

Possible fixes

A conservative short-term fix would be to reject whole-function JIT compilation when a parameter has no corresponding RECV.

A more complete fix would initialize parameter CV slots from args_ptr using the op-array parameter/CV metadata, independently of whether the interpreter bytecode retained RECV.

It would also be useful to add an integration test that goes through the normal compiler and VM call path with an untyped function receiving runtime integer arguments.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions