Autoloading redefined and composer.json

Autoloading is an essential part of PHP applications, required due to the nature of PHP itself. The common standard defined by the “composer dependency manager” is used across the majority of frameworks and components, and as explained on the documentation page, autoloading can eat up as much as 100ms of request time. Let’s have a look at how PeachPie reduces it to zero.

Introduction

In C# or Java, classes are simply there and available to be used; in PHP, however, classes have to be declared upon each request. This declaration comes with a script inclusion, a mechanism of maintaining it, and significant overhead. It’s as if we had C++ code that’s being interpreted over and over.

This is simply how PHP works. User classes (and interfaces and traits) have to be declared before they can be used. The programmer can either manually include all script files containing the class declaration at the very beginning of each request or they can take advantage of the so-called autoloading. Autoloading makes the class declaration lazy – allowing to process a script file once and when a class contained in it is actually used.

Once a type is used in PHP, it is looked up in a hashtable to find the type descriptor. If it’s not yet declared, autoload functions get invoked.

It’s a bit like building a pyramid from top to bottom, but that’s how it is in PHP for historical reasons. Although the programmer knows which class they use and where it is, it must be included lazily with a complex mechanism of autoloading and caching upon every request.

Current state – composer and generated stubs

This is where the “composer dependency manager” [ref] comes into play. Although some projects handle class declarations on their own (usually they simply include all the necessary script files at the beginning of each request), in bigger code bases this is nearly impossible. The composer lets the programmer describe declaratively in which files specific classes can be found (with class maps or PSR conventions) and generates code that handles the autoloading for you.

{
    "autoload": {
        "psr-4": { "Monolog\\": ["src/", "lib/"] }
    }
}

The composer generates code that handles autoloading in the runtime. The generated code registers its autoloader function and performs the magic when needed.

Performance impact

Nevertheless, autoloading has to be performed; basically using 1000 classes or interfaces during a single request costs you 1000 autoload calls, script inclusions, and hashtable lookups.

Let’s measure an empty Laravel 7 blog installation. The results give us the very minimum overhead caused by autoloading, even if you don’t extend the application or fill it with more content. We can profile the app on PHP 7.4.1 and on .NET Core 3.1 compiled with PeachPie.

Total time spent autoloading (PHP 7.4) on an empty Laravel blog – 26%, during which the autoloader includes 418 script files to declare a type.
Legacy autoloading when running on PeachPie (.NET Core 3.1). We make use of the standard Visual Studio CPU profiler; the autoloading method took 14% of request time (it’s 7.2% in a single request, which took up 50% of the CPU)

As the profiling above clearly shows, the autoloading of 418 types may consume about 14%-26% of the time. And this is despite the PHP code running on an SSD with caching enabled, and even thoguh we keep optimizing the autoloading itself, it is still 418 operations to be performed over and over.

The goal is to get rid of this completely.

Compiler and runtime advantages

Although the composer does its best to optimize the whole process, generates the inclusion map, and caches it in the opcache, it still has to do the same job over and over.

So while we’re writing a compiler from scratch, why not fix this historical architecture flaw? The compiler sees all the types in the project, hence it knows there is exactly one declaration of MyClass; therefore, it can skip all the autoloading nonsense. However, we do have to keep in mind that there might be side effects and compatibility issues, so we can’t just mindlessly skip everything. Still, we can take the risk and behave a bit differently in some cases in order to gain some performance.

IL bytecode; directly instantiates the class on .NET, if possible.

Profiling in Visual Studio gives a lot of insights on what’s going on out of the box. For example, we can see Laravel likes to throw exceptions to control the program flow, which isn’t exactly best practice. As a result, the PeachPie runtime collects a lot of stack traces for no reason (10% time). We can also observe that autoloading concatenates a lot of strings in order to generate the autoload map. Those are just a few hints we can make use of in order to optimize the code in the future.

Diagnostic Tools in Visual Studio showing the profiling of a few requests
(.NET Core 3.1).

Compiling auto-loaded code

The build task reads composer.json file in the root of the project, as described at docs.peachpie.io/php/composer-json/. It processes and resolves the rules in its "autoload" section resulting in the following MSBuild item groups to be filled in with the autoload map:

  • @(Autoload_PSR4): a set of namespace prefixes together with a directory containing the type declarations. Each item thus has the metadata "Prefix" and "Path". The autoloader is supposed to look in “Path” subdirectories, matching the requested type fully qualified name.
  • @(Autoload_ClassMap): a set of files containing types. This is usually used when a PSR rule can’t fit the program’s directory structure. Any class/interface/trait in these files is marked to be autoloaded.
  • @$(Autoload_Files): is a set of files to be automatically included when a request starts. This has nothing to do with type auto-loading, although it specifies files containing other declarations, such as constants and functions.

The compiler gets the rules above and treats the types matching these rules as though they were always statically declared. However, there’s a caveat.

We cannot just ignore all the processes and skip the autoloading, which is usually a part of the application’s logic itself. But we can skip anything without side effects, and we can take advantage of the rules defined in the composer.json file. This file tells us what classes are meant to be autoloaded and thus what classes were expected to be declared anywhere they are used (this may sound strange for programmers coming from the area of compiled languages). The compiler makes use of these rules and performs two actions:

  • Annotates types with metadata:
    [PhpTypeAttribute("MyClass", AutoloadFlag = 0/1/2)]
    where AutoloadFlag‘s value depends on several possible autoloading situations:
    0: the class does not match any "autoload" rule.
    1: it matches an "autoload" rule, but the containing file may have side-effects, or the class’s base type may have a side-effect. This means the file contains not just the class declaration itself but also other code or more class declarations.
    2: it matches an "autoload" rule without side-effects.
  • Emits the use of the type accordingly: (e.g. compiling new MyClass;)
    Once MyClass is used in the code, the compiler has to resolve the type and:
    • Worst case: let the runtime resolve the class dynamically in case the class name is ambiguous or unknown at compile time.
      .call object Context.Create("MyClass")
    • Better case: use the type’s token directly in case the class gets resolved. However, if AutoloadFlag is 0 or 1, it has to emit additional code that lets the runtime simulate the autoloader and eventually throw an error if the class doesn’t get declared dynamically.
      .call void Context::EnsureDeclared<MyClass>()
      .newobj instance void MyClass::.ctor()
    • Best case: the AutoloadFlag is 2, the inclusion of the containing file does not have any side-effect besides declaring the class itself.
      .newobj instance void MyClass::.ctor()

Example:

  • composer.json defines PSR-0 rule, which gets translated to the corresponding PSR-4. It states that any type prefixed with "X/" will be looked for in the “src/” and “inc/” directories. E.g. resolving class “X\Y\Z” must be declared either in “src/X/Y/Z.php” or in “inc/X/Y/Z.php”.
{
  "autoload": {
    "psr-0": {
      "X\": [ "src/", "inc/" ]
    }
}
  • The corresponding TaskItem is added to the @(Autoload_PSR4) MSBuild item group.
  • When the compiler compiles class “X\Y\Z”, it matches the type name with the rule above and annotates it with AutoloadFlag, either 1 or 2.
[PhpType("X\Y\Z", "src/X/Y/Z.php", autoloadflag: 2)]
public class Z {
  // ...
}
  • Any uses of the class Z may now be optimized; either by ignoring autoload entirely (autoloadflag: 2) or by including the containing file directly without invoking the complex autoload mechanism (autoloadflag: 1).
// new Z;
context.EnsureTypeDeclared<X.Y.Z>(); // if autoload != 2
.newobj instance void Z::.ctor() 

Runtime simulating life-cycle

By skipping the autoloading mechanism, we might run into compatibility issues. PHP is a dynamic language and it often makes use of reflection and BCLs like get_declared_classes();. The advantage of running the application as a single process with threads is that we can declare the 2‘s once at the start of the application, in the process static context. Since 2‘s are types marked as safe to be autoloaded eventually, we can just pretend someone already referenced them and thus declared them without a side-effect.

In the case of 1‘s, the runtime remembers them once at the start of the application and builds a fast immutable dictionary mapping Type to a Script file (which is compiled as a static method 🤯). Once EnsureDeclared<T>() gets called, the runtime checks whether the type is already declared in the current context (using an optimized hashtable), and eventually simulates the autoloading by invoking the script file (the static method).

In the case of 0, it falls back to calling an autoloader as defined by the PHP code. This is usually a set of callbacks that are supposed to include the right file. After that, the runtime has to check again whether the type got declared and may then continue.

Results

So what happens to our .NET application if we apply the autoload rules? All the types get annotated with autoloadFlag, the compiler skips most of the “EnsureTypeDeclared” checks and autoload invocations, and the runtime populates the autoloaded types just once at the startup of the application.

Profiled internal autoloader after applying the autoload rules.

This all together drops autoload calls to just a few, which have the autoloadFlag value 1. This results in the CPU dropping those unnecessary inclusions from 6,1% to 0.8%, and in an overall performance increase of 10% – only thanks to skipping most of the autoloading at compile time.

Note: we’ve compiled, run, and profiled the .NET app in Debug mode, which is bad practice. We were only interested in eliminating the autoload calls.

Conclusion

As a result, if the "autoload" rules are defined properly and there are no ambiguities in the type names, the compiled code is significantly improved. Literally, in the ideal case, all the overhead caused by the dynamic type declaration and autoloading is eliminated, additionally opening the doors to further Just-In-Time optimizations.

Posted on May 31, 2020, in category Information, tags: , , , ,