Skip to content

Optimize array_intersect() for integer and string values - #23019

Open
mehmetcansahin wants to merge 2 commits into
php:masterfrom
mehmetcansahin:array-intersect-str-long-fast-path
Open

Optimize array_intersect() for integer and string values#23019
mehmetcansahin wants to merge 2 commits into
php:masterfrom
mehmetcansahin:array-intersect-str-long-fast-path

Conversation

@mehmetcansahin

Copy link
Copy Markdown
Contributor

Adds a hash-based fast path to array_intersect() when all input values are integers or strings, while preserving existing behavior and falling back to the generic implementation for other value types.

Local benchmarks show supported inputs running between 4.9x and 111.8x faster, with approximately 1.3% to 1.8% overhead in the measured fallback cases.

array_intersect() compares values as strings through a sort-based
algorithm that converts values on every comparison. When every value
in every argument is an integer or a string, equality under string
semantics maps exactly to symtable key normalization, so the result
can be computed with a single hash set pass instead. Local benchmarks
show supported inputs running between 4.9x and 111.8x faster, with
approximately 1.3% to 1.8% overhead in the measured fallback cases.

PHP_FUNCTION(array_intersect) now calls zend_parse_parameters()
itself before choosing between the fast and generic paths, making it
the call site that rejects named variadic arguments; extend the
named_params test to pin this and the single-argument fallback.
@LamentXU123

Copy link
Copy Markdown
Member

This looks sensible. But could you please provide the "Local benchmarks" you've run for us to verify. These days it's hard to tell a performance improvement without benchmarks.

@mehmetcansahin

Copy link
Copy Markdown
Contributor Author

@LamentXU123 Thanks. I reran the benchmarks on an Apple M1, comparing base 3407a6d2a04 with PR head 2e270dcd9be. Each result is the median of 11 runs.

Input Base PR Change
2 x 10 integers 1.113 us 0.135 us 8.24x faster
2 x 100,000 integers 104,655.083 us 1,096.548 us 95.44x faster
2 x 10,000 strings 1,725.658 us 191.142 us 9.03x faster
3 x 10,000 integers 11,936.917 us 108.249 us 110.27x faster
10,000 values, early fallback 7,937.938 us 8,105.664 us 2.11% slower
10,000 values, late fallback 8,033.523 us 8,181.500 us 1.84% slower

Benchmark script:

benchmark.php
<?php

declare(strict_types=1);

const TARGET_SAMPLE_NS = 100_000_000;
const SAMPLE_COUNT = 11;

function makeStrings(int $start, int $size): array
{
    $values = [];
    for ($i = $start, $end = $start + $size; $i < $end; $i++) {
        $values[] = "value_$i";
    }
    return $values;
}

function makeMixed(int $start, int $size): array
{
    $values = [];
    for ($i = $start, $end = $start + $size; $i < $end; $i++) {
        $values[] = ($i & 1) === 0 ? $i : (string) $i;
    }
    return $values;
}

function scenarios(): array
{
    $fallbackFirst = range(0, 9_999);
    array_unshift($fallbackFirst, 0.5);

    $fallbackLast = range(0, 9_999);
    $fallbackLast[] = 0.5;

    return [
        'int-10' => [range(0, 9), range(5, 14)],
        'int-1000' => [range(0, 999), range(500, 1_499)],
        'int-100000' => [range(0, 99_999), range(50_000, 149_999)],
        'string-10000' => [makeStrings(0, 10_000), makeStrings(5_000, 10_000)],
        'mixed-int-string-10000' => [makeMixed(0, 10_000), makeMixed(5_000, 10_000)],
        'int-10000-3-arrays' => [
            range(0, 9_999),
            range(2_500, 12_499),
            range(5_000, 14_999),
        ],
        'fallback-float-first-10000' => [$fallbackFirst, range(5_000, 14_999)],
        'fallback-float-last-10000' => [$fallbackLast, range(5_000, 14_999)],
    ];
}

function measure(array $arrays, int $iterations): array
{
    $checksum = 0;
    $start = hrtime(true);
    for ($i = 0; $i < $iterations; $i++) {
        $checksum += count(array_intersect(...$arrays));
    }
    return [hrtime(true) - $start, $checksum];
}

$allScenarios = scenarios();
$selected = $argv[1] ?? null;
if ($selected === null || !isset($allScenarios[$selected])) {
    fwrite(STDERR, "Usage: php benchmark.php <scenario>\n\nScenarios:\n");
    foreach (array_keys($allScenarios) as $name) {
        fwrite(STDERR, "  $name\n");
    }
    exit(1);
}

$arrays = $allScenarios[$selected];
$iterations = 1;
do {
    [$elapsed] = measure($arrays, $iterations);
    if ($elapsed >= TARGET_SAMPLE_NS || $iterations >= 1_048_576) {
        break;
    }
    $iterations *= 2;
} while (true);

measure($arrays, $iterations);

$samples = [];
$checksum = 0;
for ($sample = 0; $sample < SAMPLE_COUNT; $sample++) {
    [$elapsed, $sampleChecksum] = measure($arrays, $iterations);
    $samples[] = $elapsed / $iterations;
    $checksum ^= $sampleChecksum;
}

sort($samples);
$median = $samples[intdiv(count($samples), 2)];

printf(
    "%s iterations=%d samples=%d median_us=%.3f min_us=%.3f max_us=%.3f checksum=%d\n",
    $selected,
    $iterations,
    SAMPLE_COUNT,
    $median / 1_000,
    $samples[0] / 1_000,
    $samples[array_key_last($samples)] / 1_000,
    $checksum,
);

@LamentXU123
LamentXU123 requested a review from arnaud-lb August 4, 2026 14:16
@LamentXU123

Copy link
Copy Markdown
Member

I don't love the additional code complexity, but the benchmark result seems worth it :/

@arnaud-lb

Copy link
Copy Markdown
Member

Current algo:

  • Build a sorted list for each array: O(m (n log n))
  • Find intersections: O(mn)

New algo:

  • Iterate first array: O(n)
  • Flip first array: O(n)
  • Find intersections: O(mn)

New algo is clearly superior.

Could the same algorithm be used in all cases, not only string|int arrays? array_intersect() converts values to string before comparison, so all values can be used as hash index. This would eliminate the fallback overhead.

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.

3 participants