[00:03] simple HTML 5 parsing with Symfony DOMC crawler take 100 milliseconds while analyzing a magenta 2 store. The fix in this case brought down the performance to roughly 10 milliseconds in order of magnitude faster. And this makes for a [00:20] great story to tell about us developers making assumptions about the performance of code while writing it and then completely misjudging how long it takes. And a usual disclaimer when investigating performance problems, it's [00:35] not a matter of assigning blame uh to who introduced the bottleneck. We are here to learn from them for the future. Mo, I am Benjamin and my work is focused on PHP performance topics for the last 10 years, helping thousands of [00:49] developers, companies, and open-source projects along the way. As developers, we usually don't have the performance numbers available for every line of code that we write. So we learn to live with horistics and assumptions to do this on [01:02] the fly while we write code. These horistic ticked off my alarm bells when analyzing a magenta 2 store. Tideways profiler showed the module link preload profiler showed the module link preload from eo. I came across its link paraser [01:17] at link headers from response method and it constructs a symfony dom crawler instance with the HTML of the response instance with the HTML of the response taking 100 milliseconds. [01:30] This is entirely unexpected for me as my assumption was that it's using DOM document under the hood a PHP extension that is written in C and even with a very large HTML response it should not take that long maybe up to 10 [01:47] milliseconds. That is my feeling how long this code should take based on my experience building PHP code for the last 25 years. And because we don't have live profiling running for every line that we write, developers learn in their [02:01] career by experience to estimate how much time various functions and algorithms roughly take. Let's say PHP string or array manipulation functions take 0.001 millisecond roughly in my experience. A [02:18] file system call 10 times more 0.01 01 milliseconds. Then the next order of magnitude may be some radis call to memory 0.1 milliseconds. And then database queries. Let's say an SQL query with a wear clause based on primary key [02:35] 1 millisecond. SQL query with a wear clause on different columns maybe some complexity around 10 milliseconds. And then SQL query with a wear clause on columns and no index. let's say 100 milliseconds or even many seconds and so [02:52] on and so on. I have so many different assumptions in my mind about how long should things take. They pop up once I write the code. It's like a background process running in my mind evaluating constantly 1 [03:08] milliseconds here, 100 milliseconds there and so on and so on to get a rough estimate how long a controller, a command or something will run in the end. So the question to me was why is the Symfony domc crawler so slow and you [03:23] can quickly find out by going a bit deeper in the stack in the profiler to reveal that the actualist parsing is done by the mastermind's HTML 5 parser [03:35] go down here crawler construct crawler at at content at HTML content parse HTML at at content at HTML content parse HTML string crawler parse HTML 5 and then [03:47] string crawler parse HTML 5 and then from there we the HTML 5 parse and tokenizer parse is called from the mastermind's name space and the DOM document is not uh actually called at all. This is not how I remember Symfony [04:02] DOM crawler working when I used it a few years back and that would prove me right. The optional support for HTML 5based parsing was added in 2019 in PR29306 and it was made the default only with [04:18] and it was made the default only with Symfony 7.0 in 2022 with PR44170. used it a lot or didn't need it to be like super fast maybe I came across this [04:33] but it wasn't relevant enough and now I saw it for this customer hitting really hard before this change symfony domc crawler would use the PHP extension DOM [04:45] document under the hood which only parses HTML 4 compliant and that created a lot of subtle and annoying bugs when parsing HTML L5. And so it makes sense [04:58] that uh the Symfony team fixes these passing errors by introducing a HTML 5 paraser. But the problem is when that paser is 10 times slower than the paser is 10 times slower than the original code. And this caused other [05:13] people to report issues on the Symfony project pointing this performance change out. This pasa activates itself only when the beginning of the content starts when the beginning of the content starts with the HTML 5 dock type and when the [05:28] with the HTML 5 dock type and when the use HTML 5 pasa flag to the constructor use HTML 5 pasa flag to the constructor is true which uh since uh 2022 is the default back to the method add link headers from response. So it doesn't [05:44] have to be 100% perfect. we don't need to perfectly add all styles, scripts, to perfectly add all styles, scripts, and images um using a a preload tag. The reason for this is that uh the browser will eventually catch those um anyways [06:00] will eventually catch those um anyways parsing HTML 5 and then load them. It might take a little bit longer. However, um using this uh 10 times slower paraser for HTML 5, what we are doing essentially is we are affecting the [06:15] backend performance to render the time to first bite. So this is a critical pass in the performance. It affects the Google web vital quite negatively and um [06:27] 100 milliseconds are just not worth this simple performance optimization. The browser will not catch up this 100 milliseconds. uh it's on its own. So what we can do now is we write ourselves a little test script. I did that here. [06:43] So I'm bootstrapping the magenta codebase that's necessary to be able to dependencies. Then I put um HTML code into the uh [06:56] Then I put um HTML code into the uh dummy response object. um I use that from the customer that I talked at the beginning from with the 100 milliseconds beginning from with the 100 milliseconds slowdown and then I run the link paraser [07:08] and then I check how many lines in the response contain preloading. So this way I see when I change that to run with um something with a different kind of paraser if the result is similar or it doesn't find any results anymore. [07:27] So then what I'm doing is I run tight with profiler with profiler and the reason for this is that I want to see the uh performance in the worst case. So let's open that. We go to the [07:44] case. So let's open that. We go to the call graph and we look for the link para call graph and we look for the link para parse method. So, it only takes 67 milliseconds here. So, it's faster probably because on my uh MacBook Pro M [07:59] probably because on my uh MacBook Pro M uh M4 um uh the code the CPU code doing here is just way faster than on on a server that has maybe more generic um uh hardware. So, still 66 milliseconds are way too [08:15] long going through the slowest pass. We still see it's crawler construct related still see it's crawler construct related and um we can go through it and see uh further down it's using the masterminds library. So that is what we wanted to [08:29] show. Let's go back to the code and then in Let's go back to the code and then in the crawler we can see we can disable the crawler we can see we can disable use HTML 5 passer. So let's do that. [08:44] use HTML 5 passer. So let's do that. use htm. I'm using um named arguments arguments that would otherwise interfere. So I'm doing this change running the profiler again [08:59] running the profiler again and then I can directly jump um to compare it against the previous trace trace and I can see it's running much faster. [09:11] and I can see it's running much faster. So 139 milliseconds to 193. Let's check in the core graph. The reason for this yeah tokenizer attribute 6,000 calls less. So this is the most the biggest improvement. And the reason for this [09:25] improvement. And the reason for this essentially is that crawler construct which is the entry point to the using of the DOM crawler is now 50 um 52 [09:37] the DOM crawler is now 50 um 52 milliseconds faster. And uh this is like the order of magnitude improvement that we were looking for and we were we were looking for and we were expecting. So um with DOM document [09:50] expecting. So um with DOM document uh if we look at the trace uh if we look at the trace and look again for link passer pass we and look again for link passer pass we see it's now um five times faster than [10:02] see it's now um five times faster than in the code before. And um for like different setups and scenarios, I've seen this to be 10 times faster as well. seen this to be 10 times faster as well. So it's a very big performance change to [10:14] use uh the mastermind's code versus using DOM document. So are we done now? Not really. Let's look at the code again and see if it catches the same amount of links. It does that. So from the feature [10:29] perspective, at least for this HTML code, it produces the same response. However, um PHP 8.4 um PHP 8.4 um added support for HTML 5 parsing. So [10:43] if we just upgrade that Magento 2 store so to use PHP 8.4, we can make use of C-levelbased HTML 5 parsing and that would make the HTML 5 parsing and that would make the code again much faster. So um what do we [10:59] need to do to support that? Uh since the link preload module is generic code, we would uh need to check for the PHP version. So using the PHP para if [11:15] PHP version ID is greater is greater um 8.4 four and then we use that flag um 8.4 four and then we use that flag here. And then we should also check that [11:31] here. And then we should also check that um Symfony is using a version that supports this change. This happens with the pull request 61475. It was merged uh last month, so in [11:45] August 2025, and it will be included in Symphony 7.4 onwards. So we need to change the code to check for that and you can use uh do that using the [11:59] composer package installed versions and then you can say get version of Symfony then you can say get version of Symfony DOM crawler and uh [12:11] then I need to check that version compare installed versions is bigger than 7.4. [12:25] So in this case I will use the HTML 5 parser uh for the crawler and in all the other cases so with lower PHP versions um in the link paraser in this preload [12:37] um in the link paraser in this preload module we use the HTML 4-based pasa with all the caveats of the pasa being not as correct as possible. correct as possible. The next problem is that um in [12:51] Symphony um 8.0 this parameter will go away. So uh we need to make an additional change here and check that we only do that if [13:06] installed version compare installed version get version compare installed version get version from Symphfony crawler is [13:19] numbers. It returns one zero or minus one. So if the left one is smaller than one. So if the left one is smaller than the right one, so 8.0000 00 0. In that [13:31] the right one, so 8.0000 00 0. In that case, we want to case, we want to um use it with the flag and in the other case we want to or have to use it with the original code [13:46] um because otherwise it will create a fatal error because the um argument doesn't exist anymore under that name and the code will not like this. So this way we have it working. Uh let's [14:01] So this way we have it working. Uh let's check again. Uh we call tideways run and check again. Uh we call tideways run and we can see the output is still uh much lower than um with in the case without this fix. [14:15] um with in the case without this fix. And we can verify in the code that we are running dom document load HTML and we are not using the mastermind's code. So it it constructs it but it's not using the code [14:31] it but it's not using the code and um the link parser pass method is also quite fast compared to the slow code we looked at before. This story shows how the assumptions we make about performance can quickly be [14:45] off by an order of magnitude or even more and that we should verify them with actual production numbers every once in a while using a production ready profiler such as Tideways. If you like to stay informed about PHP performance [14:59] topics, please subscribe to this channel on YouTube or subscribe to the newsletter. The link is in the description. Bye.