diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml new file mode 100644 index 0000000..027e757 --- /dev/null +++ b/.github/workflows/benchmarks.yaml @@ -0,0 +1,38 @@ +name: Benchmarks + +on: [pull_request] +jobs: + run: + runs-on: ubuntu-latest + name: Performance regression check + steps: + - name: Checkout base branch + uses: actions/checkout@master + with: + ref: ${{ github.base_ref }} + - name: Install PHP + uses: shivammathur/setup-php@master + with: + php-version: '8.5' + tools: 'composer:v2' + extensions: mbstring, dom, soap + coverage: none + - name: Install dependencies (base) + run: composer update --prefer-dist --no-progress --no-suggest + - name: Benchmark baseline (base branch) + run: | + vendor/bin/phpbench run benchmarks/ \ + --tag=baseline \ + --store \ + --report=aggregate + - name: Checkout PR branch + uses: actions/checkout@master + with: + clean: false + - name: Install dependencies (PR) + run: composer update --prefer-dist --no-progress --no-suggest + - name: Benchmark PR branch vs baseline + run: | + vendor/bin/phpbench run benchmarks/ \ + --ref=baseline \ + --report=aggregate diff --git a/.gitignore b/.gitignore index a6c661f..2f2cea7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor composer.lock .phpunit.cache .php-cs-fixer.cache +.phpbench diff --git a/benchmarks/BenchSoapClient.php b/benchmarks/BenchSoapClient.php new file mode 100644 index 0000000..882b6ca --- /dev/null +++ b/benchmarks/BenchSoapClient.php @@ -0,0 +1,21 @@ +mockResponse; + } +} diff --git a/benchmarks/ComplexTypeBenchTrait.php b/benchmarks/ComplexTypeBenchTrait.php new file mode 100644 index 0000000..fac22d7 --- /dev/null +++ b/benchmarks/ComplexTypeBenchTrait.php @@ -0,0 +1,254 @@ + */ + protected array $items; + protected SoapResponse $literalResponse; + protected SoapResponse $encodedResponse; + + protected BenchSoapClient $extSoapLiteral; + protected BenchSoapClient $extSoapEncoded; + protected string $minimalResponse; + protected string $fullResponse; + + public function setUp(): void + { + $this->singleItem = (object) [ + 'id' => 42, + 'name' => 'Widget Pro', + 'description' => 'A high-quality widget for professional use', + 'price' => 29.99, + 'quantity' => 100, + 'active' => true, + 'sku' => 'WDG-PRO-001', + 'category' => 'Electronics', + 'address' => (object) [ + 'street' => '123 Main St', + 'city' => 'Springfield', + 'zip' => '62701', + ], + ]; + + $this->items = []; + for ($i = 0; $i < 500; $i++) { + $this->items[] = (object) [ + 'id' => $i, + 'name' => 'Widget ' . $i, + 'description' => 'Description for widget ' . $i, + 'price' => 9.99 + $i, + 'quantity' => $i * 10, + 'active' => $i % 2 === 0, + 'sku' => 'WDG-' . str_pad((string) $i, 5, '0', STR_PAD_LEFT), + 'category' => 'Category ' . ($i % 10), + 'address' => (object) [ + 'street' => $i . ' Elm St', + 'city' => 'City ' . ($i % 50), + 'zip' => str_pad((string) ($i % 99999), 5, '0', STR_PAD_LEFT), + ], + ]; + } + + // php-soap/encoding + $this->literalDriver = $this->buildPhpSoapDriver('literal'); + $this->encodedDriver = $this->buildPhpSoapDriver('encoded'); + + $this->literalResponse = new SoapResponse( + $this->literalDriver->encode('test', [$this->singleItem])->getRequest() + ); + $this->encodedResponse = new SoapResponse( + $this->encodedDriver->encode('test', [$this->singleItem])->getRequest() + ); + + // ext-soap + $this->extSoapLiteral = $this->buildExtSoapClient('literal'); + $this->extSoapEncoded = $this->buildExtSoapClient('encoded'); + + $this->minimalResponse = '' + . '' + . '' + . ''; + + $this->fullResponse = '' + . '' + . '' + . '42Widget Pro' + . 'A high-quality widget for professional use' + . '29.99100true' + . 'WDG-PRO-001Electronics' + . '
123 Main StSpringfield62701
' + . '
' + . '
'; + + // Warmup ext-soap + $this->extSoapLiteral->mockResponse = $this->minimalResponse; + $this->extSoapLiteral->__soapCall('test', ['testParam' => $this->singleItem]); + $this->extSoapEncoded->mockResponse = $this->minimalResponse; + $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]); + } + + private function buildPhpSoapDriver(string $use): Driver + { + $encodingStyle = $use === 'encoded' + ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"' + : ''; + + $wsdl = << + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WSDL; + + $wsdlObject = (new Wsdl1Reader( + new CallbackLoader(static fn (): string => $wsdl) + ))('file.wsdl'); + + $registry = EncoderRegistry::default(); + $metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata(); + + return Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry); + } + + private function buildExtSoapClient(string $use): BenchSoapClient + { + $encodingStyle = $use === 'encoded' + ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"' + : ''; + + $wsdl = << + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WSDL; + + $wsdlFile = tempnam(sys_get_temp_dir(), 'wsdl_') . '.wsdl'; + file_put_contents($wsdlFile, $wsdl); + + $client = new BenchSoapClient($wsdlFile, [ + 'trace' => true, + 'cache_wsdl' => WSDL_CACHE_NONE, + ]); + + unlink($wsdlFile); + + return $client; + } +} diff --git a/benchmarks/DecodeBench.php b/benchmarks/DecodeBench.php new file mode 100644 index 0000000..1ace581 --- /dev/null +++ b/benchmarks/DecodeBench.php @@ -0,0 +1,99 @@ +literalDriver->decode('test', $this->literalResponse); + } + + #[Groups(['literal', 'php-soap', 'x500'])] + public function benchLiteral_x500(): void + { + $response = $this->literalResponse; + foreach ($this->items as $item) { + $this->literalDriver->decode('test', $response); + } + } + + // -- php-soap/encoding: encoded -- + + #[Revs(100)] + #[Groups(['encoded', 'php-soap', 'x1'])] + public function benchEncoded_x1(): void + { + $this->encodedDriver->decode('test', $this->encodedResponse); + } + + #[Groups(['encoded', 'php-soap', 'x500'])] + public function benchEncoded_x500(): void + { + $response = $this->encodedResponse; + foreach ($this->items as $item) { + $this->encodedDriver->decode('test', $response); + } + } + + // -- ext-soap: literal (includes encode overhead) -- + + #[Revs(100)] + #[Groups(['literal', 'ext-soap', 'x1'])] + public function benchExtSoapLiteral_x1(): void + { + $this->extSoapLiteral->mockResponse = $this->fullResponse; + $this->extSoapLiteral->__soapCall('test', ['testParam' => $this->singleItem]); + } + + #[Groups(['literal', 'ext-soap', 'x500'])] + public function benchExtSoapLiteral_x500(): void + { + $this->extSoapLiteral->mockResponse = $this->fullResponse; + foreach ($this->items as $item) { + $this->extSoapLiteral->__soapCall('test', ['testParam' => $item]); + } + } + + // -- ext-soap: encoded (includes encode overhead) -- + + #[Revs(100)] + #[Groups(['encoded', 'ext-soap', 'x1'])] + public function benchExtSoapEncoded_x1(): void + { + $this->extSoapEncoded->mockResponse = $this->fullResponse; + $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]); + } + + #[Groups(['encoded', 'ext-soap', 'x500'])] + public function benchExtSoapEncoded_x500(): void + { + $this->extSoapEncoded->mockResponse = $this->fullResponse; + foreach ($this->items as $item) { + $this->extSoapEncoded->__soapCall('test', ['testParam' => $item]); + } + } +} diff --git a/benchmarks/EncodeBench.php b/benchmarks/EncodeBench.php new file mode 100644 index 0000000..668e2a1 --- /dev/null +++ b/benchmarks/EncodeBench.php @@ -0,0 +1,104 @@ +literalDriver->encode('test', [$this->singleItem]); + } + + #[Groups(['literal', 'x500'])] + public function benchLiteral_x500(): void + { + foreach ($this->items as $item) { + $this->literalDriver->encode('test', [$item]); + } + } + + // -- php-soap/encoding: encoded -- + + #[Revs(100)] + #[Groups(['encoded', 'x1'])] + public function benchEncoded_x1(): void + { + $this->encodedDriver->encode('test', [$this->singleItem]); + } + + #[Groups(['encoded', 'x500'])] + public function benchEncoded_x500(): void + { + foreach ($this->items as $item) { + $this->encodedDriver->encode('test', [$item]); + } + } + + // -- ext-soap: literal -- + + #[Revs(100)] + #[Groups(['literal', 'ext-soap', 'x1'])] + public function benchExtSoapLiteral_x1(): void + { + $this->extSoapLiteral->mockResponse = $this->minimalResponse; + $this->extSoapLiteral->__soapCall('test', ['testParam' => $this->singleItem]); + } + + #[Groups(['literal', 'ext-soap', 'x500'])] + public function benchExtSoapLiteral_x500(): void + { + $this->extSoapLiteral->mockResponse = $this->minimalResponse; + foreach ($this->items as $item) { + $this->extSoapLiteral->__soapCall('test', ['testParam' => $item]); + } + } + + // -- ext-soap: encoded -- + + #[Revs(100)] + #[Groups(['encoded', 'ext-soap', 'x1'])] + public function benchExtSoapEncoded_x1(): void + { + $this->extSoapEncoded->mockResponse = $this->minimalResponse; + $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]); + } + + #[Groups(['encoded', 'ext-soap', 'x500'])] + public function benchExtSoapEncoded_x500(): void + { + $this->extSoapEncoded->mockResponse = $this->minimalResponse; + foreach ($this->items as $item) { + $this->extSoapEncoded->__soapCall('test', ['testParam' => $item]); + } + } +} diff --git a/benchmarks/scripts/ext_soap_comparison.php b/benchmarks/scripts/ext_soap_comparison.php new file mode 100644 index 0000000..ec5f9c3 --- /dev/null +++ b/benchmarks/scripts/ext_soap_comparison.php @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +WSDL; + +$wsdlFile = tempnam(sys_get_temp_dir(), 'wsdl_') . '.wsdl'; +file_put_contents($wsdlFile, $wsdl); + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +// For php-soap/encoding (accepts plain objects) +$plainItem = (object) [ + 'id' => 42, + 'name' => 'Widget Pro', + 'description' => 'A premium widget for demanding environments', + 'price' => 29.99, + 'quantity' => 100, + 'active' => true, + 'sku' => 'WDG-001', + 'category' => 'Electronics', + 'address' => (object) ['street' => '123 Main Street', 'city' => 'Springfield', 'zip' => '62701'], +]; + +// For ext-soap: nested objects need explicit type hints via SoapVar +$extSoapObj = clone $plainItem; +$extSoapObj->address = new SoapVar( + (object) ['street' => '123 Main Street', 'city' => 'Springfield', 'zip' => '62701'], + SOAP_ENC_OBJECT, + 'addressType', + 'http://test.benchmark/' +); +$extSoapItem = new SoapParam($extSoapObj, 'item'); + +// --------------------------------------------------------------------------- +// Setup: ext-soap (override __doRequest to avoid network) +// --------------------------------------------------------------------------- + +class BenchSoapClient extends SoapClient +{ + public ?string $lastRequest = null; + public ?string $mockResponse = null; + + public function __doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string + { + $this->lastRequest = $request; + return $this->mockResponse; + } +} + +$extSoap = new BenchSoapClient($wsdlFile, [ + 'trace' => true, + 'cache_wsdl' => WSDL_CACHE_NONE, +]); + +// --------------------------------------------------------------------------- +// Setup: php-soap/encoding +// --------------------------------------------------------------------------- + +$wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl'); +$registry = EncoderRegistry::default(); +$metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata(); +$driver = Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry); + +// --------------------------------------------------------------------------- +// Warmup both +// --------------------------------------------------------------------------- + +// For ext-soap, __doRequest must return a valid SOAP response. +// Use a minimal empty response for encode-only benchmarks. +$minimalResponse = '' + . '' + . '' + . ''; + +// Full response for decode benchmarks +$fullResponse = '' + . '' + . '' + . '' + . '42Widget Pro' + . 'A premium widget for demanding environments' + . '29.99100true' + . 'WDG-001Electronics' + . '
123 Main StreetSpringfield62701
' + . '
' + . '
' + . '
'; + +// ext-soap warmup +$extSoap->mockResponse = $minimalResponse; +$extSoap->__soapCall('test', ['item' => $plainItem]); +$extSoapEncoded = $extSoap->lastRequest; + +// php-soap/encoding warmup +$driver->encode('test', [$plainItem]); +$phpSoapEncoded = $driver->encode('test', [$plainItem])->getRequest(); + +echo "=== COMPARISON: php-soap/encoding vs ext-soap ===\n"; +printf("Items per run: %s\n", number_format($N)); +printf("php-soap/encoding XML: %d bytes\n", strlen($phpSoapEncoded)); +printf("ext-soap XML: %d bytes\n\n", strlen($extSoapEncoded)); + +// --------------------------------------------------------------------------- +// Benchmark: ENCODE +// --------------------------------------------------------------------------- + +echo "--- ENCODE ---\n"; + +// ext-soap encode (minimal response to avoid decode overhead) +$extSoap->mockResponse = $minimalResponse; +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $extSoap->__soapCall('test', ['item' => $plainItem]); +} +$extEncUs = (hrtime(true) - $t0) / 1e3 / $N; +printf("ext-soap: %6.1f us/item\n", $extEncUs); + +// php-soap/encoding encode +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $driver->encode('test', [$plainItem]); +} +$phpEncUs = (hrtime(true) - $t0) / 1e3 / $N; +printf("php-soap/encoding: %6.1f us/item\n", $phpEncUs); +printf("Ratio: %.1fx slower\n\n", $phpEncUs / $extEncUs); + +// --------------------------------------------------------------------------- +// Benchmark: DECODE +// --------------------------------------------------------------------------- + +echo "--- DECODE ---\n"; + +// ext-soap decode (full response with nested objects) +$extSoap->mockResponse = $fullResponse; +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $extSoap->__soapCall('test', ['item' => $plainItem]); // __doRequest returns fullResponse, ext-soap parses it +} +$extDecUs = (hrtime(true) - $t0) / 1e3 / $N; +printf("ext-soap: %6.1f us/item (encode+decode combined)\n", $extDecUs); + +// For ext-soap, we can't easily separate encode from decode since __soapCall does both. +// The decode portion is roughly: $extDecUs - $extEncUs +$extDecOnlyUs = max(0, $extDecUs - $extEncUs); +printf("ext-soap decode: ~%.1f us/item (estimated: combined - encode)\n", $extDecOnlyUs); + +// php-soap/encoding decode +$response = new SoapResponse($fullResponse); +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $driver->decode('test', $response); +} +$phpDecUs = (hrtime(true) - $t0) / 1e3 / $N; +printf("php-soap/encoding: %6.1f us/item\n", $phpDecUs); +if ($extDecOnlyUs > 0) { + printf("Ratio: %.1fx slower\n\n", $phpDecUs / $extDecOnlyUs); +} else { + printf("Ratio: n/a (ext-soap decode too fast to estimate)\n\n"); +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +echo "--- SUMMARY ---\n"; +printf("Encode: php-soap/encoding is %.1fx slower than ext-soap (%.1f vs %.1f us)\n", $phpEncUs / $extEncUs, $phpEncUs, $extEncUs); +printf("Decode: php-soap/encoding is ~%.1fx slower than ext-soap (~%.1f vs ~%.1f us)\n", + $extDecOnlyUs > 0 ? $phpDecUs / $extDecOnlyUs : 0, + $phpDecUs, + $extDecOnlyUs +); +printf("\nNote: ext-soap is a C extension; this comparison shows the overhead of a\n"); +printf("pure PHP implementation. The gap is expected and acceptable for most use cases.\n"); + +// --------------------------------------------------------------------------- +// Large payload benchmark (encode-only, both implementations) +// --------------------------------------------------------------------------- + +$targetMB = (int) ($argv[2] ?? 50); +$targetBytes = $targetMB * 1024 * 1024; + +// Calibrate using ext-soap XML size +$extBytesPerItem = strlen($extSoapEncoded); +$phpBytesPerItem = strlen($phpSoapEncoded); +$largeN = (int) ceil($targetBytes / max($extBytesPerItem, $phpBytesPerItem)); + +printf("\n=== LARGE PAYLOAD (%d MB, %s items) ===\n\n", $targetMB, number_format($largeN)); + +// ext-soap large encode +$extSoap->mockResponse = $minimalResponse; +$extTotalBytes = 0; +$t0 = hrtime(true); +for ($i = 0; $i < $largeN; $i++) { + $extSoap->__soapCall('test', ['item' => $plainItem]); + $extTotalBytes += $extBytesPerItem; +} +$extLargeEncSec = (hrtime(true) - $t0) / 1e9; +$extLargeEncMB = $extTotalBytes / 1024 / 1024; +printf("ext-soap encode:\n"); +printf(" Total: %.1f MB in %.3f s\n", $extLargeEncMB, $extLargeEncSec); +printf(" Throughput: %.1f MB/s\n", $extLargeEncMB / $extLargeEncSec); +printf(" Per item: %.1f us\n", $extLargeEncSec / $largeN * 1e6); +printf(" Peak mem: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024); + +// php-soap/encoding large encode +$phpTotalBytes = 0; +$t0 = hrtime(true); +for ($i = 0; $i < $largeN; $i++) { + $xml = $driver->encode('test', [$plainItem])->getRequest(); + $phpTotalBytes += strlen($xml); +} +$phpLargeEncSec = (hrtime(true) - $t0) / 1e9; +$phpLargeEncMB = $phpTotalBytes / 1024 / 1024; +printf("php-soap/encoding encode:\n"); +printf(" Total: %.1f MB in %.3f s\n", $phpLargeEncMB, $phpLargeEncSec); +printf(" Throughput: %.1f MB/s\n", $phpLargeEncMB / $phpLargeEncSec); +printf(" Per item: %.1f us\n", $phpLargeEncSec / $largeN * 1e6); +printf(" Peak mem: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024); + +printf("Encode ratio at scale: %.1fx slower (%.1f vs %.1f MB/s)\n", + $phpLargeEncSec / $extLargeEncSec, + $phpLargeEncMB / $phpLargeEncSec, + $extLargeEncMB / $extLargeEncSec +); + +// ext-soap large decode +$extSoap->mockResponse = $fullResponse; +$t0 = hrtime(true); +for ($i = 0; $i < $largeN; $i++) { + $extSoap->__soapCall('test', ['item' => $plainItem]); +} +$extLargeDecSec = (hrtime(true) - $t0) / 1e9; +// Subtract encode time to estimate decode-only +$extLargeDecOnlySec = max(0, $extLargeDecSec - $extLargeEncSec); +$extLargeDecMB = $largeN * strlen($fullResponse) / 1024 / 1024; + +printf("\next-soap decode (estimated):\n"); +printf(" Throughput: ~%.1f MB/s\n", $extLargeDecOnlySec > 0 ? $extLargeDecMB / $extLargeDecOnlySec : 0); +printf(" Per item: ~%.1f us\n", $extLargeDecOnlySec / $largeN * 1e6); + +// php-soap/encoding large decode +$response = new SoapResponse($fullResponse); +$t0 = hrtime(true); +for ($i = 0; $i < $largeN; $i++) { + $driver->decode('test', $response); +} +$phpLargeDecSec = (hrtime(true) - $t0) / 1e9; +$phpLargeDecMB = $largeN * strlen($fullResponse) / 1024 / 1024; + +printf("\nphp-soap/encoding decode:\n"); +printf(" Throughput: %.1f MB/s\n", $phpLargeDecMB / $phpLargeDecSec); +printf(" Per item: %.1f us\n", $phpLargeDecSec / $largeN * 1e6); + +if ($extLargeDecOnlySec > 0) { + printf("\nDecode ratio at scale: ~%.1fx slower (%.1f vs ~%.1f MB/s)\n", + $phpLargeDecSec / $extLargeDecOnlySec, + $phpLargeDecMB / $phpLargeDecSec, + $extLargeDecMB / $extLargeDecOnlySec + ); +} + +unlink($wsdlFile); diff --git a/benchmarks/scripts/large_payload_bench.php b/benchmarks/scripts/large_payload_bench.php new file mode 100644 index 0000000..8887953 --- /dev/null +++ b/benchmarks/scripts/large_payload_bench.php @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + +EOXML; + +$wsdl = << + + + + + {$schema} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +EOF; + +$wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl'); +$registry = EncoderRegistry::default(); +$metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata(); +$driver = Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry); + +// --------------------------------------------------------------------------- +// Generate realistic item data +// --------------------------------------------------------------------------- + +$streets = [ + '742 Evergreen Terrace', '221B Baker Street', '1600 Pennsylvania Avenue', + '350 Fifth Avenue', '10 Downing Street', '1 Infinite Loop', + '4059 Mount Lee Drive', '1060 West Addison Street', +]; +$cities = [ + 'Springfield', 'London', 'Washington', 'New York', 'Manchester', + 'Cupertino', 'Los Angeles', 'Chicago', 'Portland', 'Seattle', +]; +$categories = [ + 'Electronics', 'Home & Garden', 'Sporting Goods', 'Automotive Parts', + 'Books & Media', 'Health & Beauty', 'Office Supplies', 'Industrial Equipment', + 'Clothing & Apparel', 'Food & Beverages', +]; +$adjectives = [ + 'Premium', 'Professional', 'Heavy-Duty', 'Lightweight', 'Industrial', + 'Compact', 'Deluxe', 'Standard', 'Advanced', 'Ultra', +]; +$nouns = [ + 'Widget', 'Gadget', 'Component', 'Module', 'Assembly', + 'Adapter', 'Connector', 'Bracket', 'Sensor', 'Controller', +]; +$descriptions = [ + 'Designed for demanding environments and continuous operation under heavy loads', + 'Precision-engineered component manufactured to exacting tolerances', + 'Versatile solution suitable for both commercial and residential applications', + 'High-performance unit featuring advanced thermal management system', + 'Ergonomic design with reinforced housing for extended service life', + 'Cost-effective replacement part compatible with all major brands', + 'Next-generation technology delivering improved efficiency and reliability', + 'Rugged construction with IP67 rating for outdoor installations', +]; + +function buildItem(int $i): object +{ + global $streets, $cities, $categories, $adjectives, $nouns, $descriptions; + + return (object) [ + 'id' => $i, + 'name' => $adjectives[$i % count($adjectives)] . ' ' . $nouns[$i % count($nouns)] . ' MK-' . $i, + 'description' => $descriptions[$i % count($descriptions)] . ' (ref #' . $i . ')', + 'price' => round(4.99 + ($i % 500) * 0.73, 2), + 'quantity' => 1 + ($i % 9999), + 'active' => $i % 3 !== 0, + 'sku' => 'SKU-' . strtoupper(dechex($i + 0xA000)) . '-' . str_pad((string) ($i % 10000), 4, '0', STR_PAD_LEFT), + 'category' => $categories[$i % count($categories)], + 'address' => (object) [ + 'street' => ($i * 7 % 9999) . ' ' . $streets[$i % count($streets)], + 'city' => $cities[$i % count($cities)], + 'zip' => str_pad((string) (10000 + $i % 89999), 5, '0', STR_PAD_LEFT), + ], + ]; +} + +// --------------------------------------------------------------------------- +// Calibrate: encode one item to measure XML size, then derive N for ~500MB +// --------------------------------------------------------------------------- + +$sampleItem = buildItem(0); + +// Warmup +$driver->encode('test', [$sampleItem]); + +$sampleXml = $driver->encode('test', [$sampleItem])->getRequest(); +$bytesPerItem = strlen($sampleXml); + +$targetMB = (int) ($argv[1] ?? 50); +$targetBytes = $targetMB * 1024 * 1024; +$N = (int) ceil($targetBytes / $bytesPerItem); + +printf("XML size per item: %d bytes\n", $bytesPerItem); +printf("Items needed: %s (targeting %d MB)\n", number_format($N), $targetBytes / 1024 / 1024); +printf("Expected total XML: %.1f MB\n\n", ($N * $bytesPerItem) / 1024 / 1024); + +// --------------------------------------------------------------------------- +// Benchmark: Encode (generate items on the fly to avoid memory exhaustion) +// --------------------------------------------------------------------------- + +echo "=== ENCODE ===\n"; +$totalEncodeBytes = 0; +$encodedXmls = []; + +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $xml = $driver->encode('test', [buildItem($i)])->getRequest(); + $totalEncodeBytes += strlen($xml); + if ($i < 1000) { + $encodedXmls[] = $xml; + } +} +$encodeNs = hrtime(true) - $t0; + +$encodeMs = $encodeNs / 1e6; +$encodeSec = $encodeNs / 1e9; +$encodeMB = $totalEncodeBytes / 1024 / 1024; + +printf("Total XML produced: %.1f MB\n", $encodeMB); +printf("Encode time: %.3f s\n", $encodeSec); +printf("Throughput: %.1f MB/s\n", $encodeMB / $encodeSec); +printf("Per item: %.1f us\n", $encodeNs / 1e3 / $N); +printf("Peak memory: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024); + +// --------------------------------------------------------------------------- +// Benchmark: Decode +// --------------------------------------------------------------------------- + +// For decode, we cycle through the stored subset of encoded XMLs. +// This measures decode throughput over the same ~500MB volume without +// needing to keep all encoded strings in memory. + +echo "=== DECODE ===\n"; +$totalDecodeBytes = 0; +$subsetCount = count($encodedXmls); + +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $xml = $encodedXmls[$i % $subsetCount]; + $response = new SoapResponse($xml); + $driver->decode('test', $response); + $totalDecodeBytes += strlen($xml); +} +$decodeNs = hrtime(true) - $t0; + +$decodeSec = $decodeNs / 1e9; +$decodeMB = $totalDecodeBytes / 1024 / 1024; + +printf("Total XML decoded: %.1f MB\n", $decodeMB); +printf("Decode time: %.3f s\n", $decodeSec); +printf("Throughput: %.1f MB/s\n", $decodeMB / $decodeSec); +printf("Per item: %.1f us\n", $decodeNs / 1e3 / $N); +printf("Peak memory: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024); + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +echo "=== SUMMARY ===\n"; +printf("Items: %s\n", number_format($N)); +printf("Encode throughput: %.1f MB/s (%.3f s)\n", $encodeMB / $encodeSec, $encodeSec); +printf("Decode throughput: %.1f MB/s (%.3f s)\n", $decodeMB / $decodeSec, $decodeSec); +printf("Peak memory: %.1f MB\n", memory_get_peak_usage(true) / 1024 / 1024); diff --git a/benchmarks/scripts/profile_encode.php b/benchmarks/scripts/profile_encode.php new file mode 100644 index 0000000..beb43c8 --- /dev/null +++ b/benchmarks/scripts/profile_encode.php @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + +EOXML; + +$wsdl = << + + + + + {$schema} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +EOF; + +// Setup +$wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl'); +$registry = EncoderRegistry::default(); +$metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata(); +$driver = Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry); + +$item = (object) [ + 'id' => 42, 'name' => 'Widget', 'description' => 'A widget', + 'price' => 29.99, 'quantity' => 100, 'active' => true, + 'sku' => 'WDG-001', 'category' => 'Electronics', + 'address' => (object) ['street' => '123 Main', 'city' => 'Springfield', 'zip' => '62701'], +]; + +$N = (int) ($argv[1] ?? 1000); + +// Warmup +$driver->encode('test', [$item]); +$xml = $driver->encode('test', [$item])->getRequest(); +$response = new SoapResponse($xml); +$driver->decode('test', $response); + +echo "=== END-TO-END ($N iterations) ===\n"; +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) $driver->encode('test', [$item]); +printf("Encode: %.1fms total, %.1fus/item\n", ($t = (hrtime(true) - $t0) / 1e6), $t / $N * 1000); + +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) $driver->decode('test', $response); +printf("Decode: %.1fms total, %.1fus/item\n\n", ($t = (hrtime(true) - $t0) / 1e6), $t / $N * 1000); + +// ---- Break down ENCODE ---- +echo "=== ENCODE BREAKDOWN ($N iterations) ===\n"; + +// Step 1: Encoder setup (method lookup, iso creation) +$method = $metadata->getMethods()->fetchByName('test'); +$methodContext = new \Soap\Encoding\Encoder\Method\MethodContext($method, $metadata, $registry, $wsdlObject->namespaces); +$requestEncoder = new \Soap\Encoding\Encoder\Method\RequestEncoder(); +// Pre-build iso once to separate setup from execution +$encIso = $requestEncoder->iso($methodContext); + +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $requestEncoder->iso($methodContext); +} +printf("RequestEncoder->iso(): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 2: iso->to (the actual encode) +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $encIso->to([$item]); +} +printf("iso->to([\$item]): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 3: Just the inner encoder (type encoder, not envelope) +$paramType = first($method->getParameters())->getType(); +$encContext = $methodContext->createXmlEncoderContextForType($paramType); +$typeEncoder = $registry->detectEncoderForContext($encContext); +$typeIso = $typeEncoder->iso($encContext); + +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $typeIso->to($item); +} +printf("typeIso->to(\$item) (no envelope): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 4: Just XsdTypeXmlElementWriter +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + (new \Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter())( + $encContext, + \VeeWee\Xml\Writer\Builder\value('test') + ); +} +printf("XsdTypeXmlElementWriter (simple): %6.1fus/call\n\n", (hrtime(true) - $t0) / 1e3 / $N); + +// ---- Break down DECODE ---- +echo "=== DECODE BREAKDOWN ($N iterations) ===\n"; + +// Step 1: SOAP envelope parse +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + (new \Soap\Encoding\Xml\Reader\SoapEnvelopeReader())($xml); +} +printf("SoapEnvelopeReader (parse+fault): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 2: OperationReader (envelope + extract parts) +$methodMeta = $method->getMeta(); +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + (new OperationReader($methodMeta))($xml); +} +printf("OperationReader (full): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 3: Just the type decoder (from Element, no envelope parse) +$parts = (new OperationReader($methodMeta))($xml); +$partElement = first($parts->elements()); + +$returnType = $method->getReturnType(); +$decContext = $methodContext->createXmlEncoderContextForType($returnType); +$decContext = new Context( + $returnType, + $metadata, + $registry, + $wsdlObject->namespaces, + $methodMeta->outputBindingUsage() + ->map(BindingUse::from(...)) + ->unwrapOr(BindingUse::LITERAL) +); +$typeDecoder = $registry->detectEncoderForContext($decContext); +$decIso = $typeDecoder->iso($decContext); + +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $decIso->from($partElement); +} +printf("typeIso->from(\$element) (no parse): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 4: Just DocumentToLookupArrayReader +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + (new \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader())($partElement); +} +printf("DocumentToLookupArrayReader: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 5: object_data from (properties_set) +$objectDataIso = \VeeWee\Reflecta\Iso\object_data(\stdClass::class); +$propData = ['id' => 42, 'name' => 'Widget', 'description' => 'A widget', 'price' => 29.99, 'quantity' => 100, 'active' => true, 'sku' => 'WDG-001', 'category' => 'Electronics', 'address' => null]; +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + $objectDataIso->from($propData); +} +printf("object_data->from (properties_set): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +echo "\nPeak memory: " . round(memory_get_peak_usage(true) / 1024 / 1024, 1) . "MB\n"; diff --git a/composer.json b/composer.json index 3b24f95..37023f1 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ }, "autoload-dev": { "psr-4": { - "Soap\\Encoding\\Test\\": "tests/" + "Soap\\Encoding\\Test\\": "tests/", + "Soap\\Encoding\\Benchmarks\\": "benchmarks/" } }, "authors": [ @@ -37,7 +38,8 @@ "php-standard-library/psalm-plugin": "^2.3", "php-soap/engine-integration-tests": "^1.10", "php-soap/psr18-transport": "^1.8", - "guzzlehttp/guzzle": "^7.8" + "guzzlehttp/guzzle": "^7.8", + "phpbench/phpbench": "^1.6" }, "config": { "allow-plugins": { diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..4b124e9 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://phpbench.readthedocs.io/en/latest/configuration.html", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.file_pattern": "*Bench.php", + "runner.php_config": { + "xdebug.mode": "off" + } +}