Optimize array_intersect() using hash-based matching - #23019
Optimize array_intersect() using hash-based matching#23019mehmetcansahin wants to merge 4 commits into
Conversation
|
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. |
|
@LamentXU123 Thanks. I reran the benchmarks on an Apple M1, comparing base
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,
); |
|
I don't love the additional code complexity, but the benchmark result seems worth it :/ |
|
Current algo:
New algo:
New algo is clearly superior. Could the same algorithm be used in all cases, not only |
2e270dc to
8fb2b5e
Compare
|
I tried the universal hash approach and updated the implementation to use it for all value types. The type-based fallback is now gone. The former fallback cases are about 109–111x faster, float-only arrays are about 24x faster, and the existing integer/string cases remained neutral or improved by up to 22%. I also added empty-input short-circuiting, while preserving the first array's key/bucket metadata, and documented the observable conversion-order differences in |
8fb2b5e to
367bbff
Compare
|
I pushed the latest fixes. Updated benchmark results:
The safety fix adds approximately 2–20% overhead compared with the previous hash implementation, while remaining 7.44–114.68× faster than the base implementation. |
arnaud-lb
left a comment
There was a problem hiding this comment.
This looks good to me apart from a few nits. I will merge once those are resolved.
Feel free to add a NEWS entry.
| ZEND_HASH_FOREACH_KEY(result, num_key, key) { | ||
| if (zend_bitset_in(delete_bitset, result_pos)) { |
There was a problem hiding this comment.
Did you consider using ZEND_BITSET_FOREACH() here?
There was a problem hiding this comment.
The bitset tracks live-entry positions, not bucket indexes. Since zend_array_dup() can compact holes after conversion, using ZEND_BITSET_FOREACH() would still require mapping each position back to the corresponding bucket.
Replaces
array_intersect()'s sort-based matching with a hash-based implementation for calls with at least two arrays. Integer and string values use normalized hash keys directly; other values are converted according to the existing string-comparison semantics. Single-array calls retain the generic path.This removes the type-based fallback and makes matching expected-linear. Empty operands short-circuit after argument validation while preserving the first array's key and bucket metadata.
String conversions now occur while scanning instead of during sort comparisons. This can change warning and
__toString()invocation counts and order, which conversion exception is reached, and results for stateful__toString()implementations. This is documented inUPGRADING.Compared with the previous PR head on an Apple M1, existing integer/string cases are neutral or up to 22% faster, the former fallback cases are about 109–111x faster, and float-only arrays are about 24x faster.
Verification includes the configured full test suite with 0 failures, all relevant
array_intersect*tests, the resource-heavy hard-timeout test, and 4,000 differential cases matching the previous implementation for stable conversions and array metadata.