Eloquent performance: improve caching in HasAttributes::getCastType() and HasAttributes::isEncryptedCastable() #57101
Replies: 28 comments
-
Hi,
|
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
I changed the code a bit to nested array $...Cache[static::class][$key] which performs even better. The mutator caches in HasAttributes use $...Cache[get_class($this)][$key], but I think static::class should provide better performance with same functionality. |
Beta Was this translation helpful? Give feedback.
-
Interesting how this was missed when it was first implemented. The fact that the parent protected static is shared for all child models. Can't this lead to issues if in the same PID one method sets the cache for a key to something and for another model the same key is casted to another type? Model 1 'col' needs cast fom json to array Model 2 UPDATE: The initial cache holds in it ['decimal'=> 'decimal'] for any model. @thomas-0816 so you putting in there also the FQN of the model just increases the static variable needed memory. What is the advantage? The key was not part of the cache and in your versions it is. The speed gain must be attributed to |
Beta Was this translation helpful? Give feedback.
-
Better performance for getCastType() and isEncryptedCastable(), resulting in better performance for castAttribute(). |
Beta Was this translation helpful? Give feedback.
-
@thomas-0816 can you please try this: protected function getCastType($key)
{
static $test;
$castType = ($test[static::class] ??= $this->getCasts())[$key];
if (isset(static::$castTypeCache[$castType])) {
return static::$castTypeCache[$castType];
}
if ($this->isCustomDateTimeCast($castType)) {
$convertedCastType = 'custom_datetime';
} elseif ($this->isImmutableCustomDateTimeCast($castType)) {
$convertedCastType = 'immutable_custom_datetime';
} elseif ($this->isDecimalCast($castType)) {
$convertedCastType = 'decimal';
} elseif (class_exists($castType)) {
$convertedCastType = $castType;
} else {
$convertedCastType = trim(strtolower($castType));
}
return static::$castTypeCache[$castType] = $convertedCastType;
} And if this is faster then you can try moving the caching in the getCasts function by static::class to see the results. That should improve also other functions that call getCasts function. |
Beta Was this translation helpful? Give feedback.
-
Yes caching getCasts() also makes an improvement, great! From the previous 15.1s it gets to 12.0s. Total from 21.7 it gives 44% better performance. Caching isDateAttribute() gets 11.8s. Summary of changes I made:
|
Beta Was this translation helpful? Give feedback.
-
Another performance improvement might come from using typed properties and property hooks, but I'm not sure what's the current status with Eloquent on this. |
Beta Was this translation helpful? Give feedback.
-
The active record does not work well with attributes(columns) as model properties. Also property hooks are not yet mature enough because the array does not fully work. You can read more in the php documentation about it. |
Beta Was this translation helpful? Give feedback.
-
Beta Was this translation helpful? Give feedback.
-
Adding php method return type array to getCasts() is also a breaking change. I think it's more important to keep the code consistent. Local static is currently not used in Eloquent, so having a protected property should be the way to go here regarding consistency. Having local static also gives no option to clear the cache. Performance for changing only getCasts():
=> gives 13.1s
=> gives 13.3s
=> gives 13.5s
=> gives 12.9s
|
Beta Was this translation helpful? Give feedback.
-
@thomas-0816 We are trying to understand the autoincrement casting to int. Is this happening only on inserts? Eloquent can be bypassed on lists for example see https://marius-ciclistu.medium.com/eloquent-is-faster-when-used-without-hydration-25e80c5eb135 As you can see here https://laravel-crud-wizard.com/laravel-12-free/laravel-lumen-crud-wizard#clients on list the primary key comes int not string when we bipass the eloquent. ![]() If the cast to int is needed for autoincrement only on insert than we can avoid the cache because the casts property is the cache. What do you think? |
Beta Was this translation helpful? Give feedback.
-
Can we get confirmation that the fact that the autoincrement is casted to int removes the need of the if from getCasts() ? public function getCasts()
{
if ($this->getIncrementing()) {
return array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts);
} public function processInsertGetId(Builder $query, $sql, $values, $sequence = null)
{
$query->getConnection()->insert($sql, $values);
$id = $query->getConnection()->getPdo()->lastInsertId($sequence);
return is_numeric($id) ? (int)$id : $id;
} |
Beta Was this translation helpful? Give feedback.
-
I guess id as primary key will be automatically added if incrementing is true, so getCasts() also needs to include "id" => "int". Also if the primary key is decimal, pdo will return a string in selects and we will need to cast it to float. |
Beta Was this translation helpful? Give feedback.
-
@thomas-0816 We can't find the initial commit that introduced that if in getCasts. Maybe it was like that from the beginning. This is just a leftover from the start of laravel maybe that now we discovered together that it introduces delays in hydrating. @taylorotwell can you shed some light on this please? @thomas-0816 the time for: public function getCasts()
{
return $this->casts;
} should be less because it will not cast the pk nor it will use array_merge. |
Beta Was this translation helpful? Give feedback.
-
class Connector
{
use DetectsLostConnections;
/**
* The default PDO connection options.
*
* @var array
*/
protected $options = [
PDO::ATTR_CASE => PDO::CASE_NATURAL,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL,
PDO::ATTR_STRINGIFY_FETCHES => false, // it is false
PDO::ATTR_EMULATE_PREPARES => false,
]; And if you set it to true makes sense to return string not int because you asked for strings.
Converting decimals to floats will introduce errors (calculation or representation errors). They should be handled as strings with BCMATH for example. |
Beta Was this translation helpful? Give feedback.
-
I had a PR that improved this drastically, although I attacked it from The change to the unit tests in that PR is proof that casting is executed more frequently than expected. |
Beta Was this translation helpful? Give feedback.
-
@DanielCHood can you give your opinion if removing that if from getCasts function is a breaking change? |
Beta Was this translation helpful? Give feedback.
-
@macropay-solutions yes, technically. The reason being is that right now the primary key, when auto incrementing, is treated as having the primary key in the |
Beta Was this translation helpful? Give feedback.
-
@DanielCHood but it is coming from db int and on inserts it is already casted to int if it is numeric. Update. Anyway the if can be removed independently by those who don't use casts. For our demo page we did just that. |
Beta Was this translation helpful? Give feedback.
-
@macropay-solutions but it's also executing that casting on rows coming from select queries. If I remove that, my data is strings by default. Commenting out that line gives me this when I select a collection and print it via
|
Beta Was this translation helpful? Give feedback.
-
@thomas-0816 I'm curious if you could cherry pick #51617 and run your test again. |
Beta Was this translation helpful? Give feedback.
-
Thank you for the reply. For us that does not happen. What db are you using? @thomas-0816 then your cache solution is an option if it will ever be accepted. |
Beta Was this translation helpful? Give feedback.
-
@macropay-solutions MySQL PDO, attributes are
|
Beta Was this translation helpful? Give feedback.
-
@DanielCHood that explains it... Ok thank you. |
Beta Was this translation helpful? Give feedback.
-
@DanielCHood @thomas-0816 // model
public function __construct(array $attributes = [])
{
if ($this->incrementing) {
$this->casts[$this->getKeyName()] ??= $this->getKeyType();
}
$this->bootIfNotBooted();
$this->initializeTraits();
$this->syncOriginal();
$this->fill($attributes);
}
// Has Attributes
public function getCasts()
{
return $this->casts;
} This would remove the need for cashing the casts by FQN. Also it would be fully backward compatible. Update. |
Beta Was this translation helpful? Give feedback.
-
I think castedAttributeCache can only work if the casting itself is deterministic. Clearing the cache with every set operation also costs some performance. So I'm not in favor of castedAttributeCache. |
Beta Was this translation helpful? Give feedback.
-
That’s fair, I opted for it for safety (worrying about to places changing the property) whilst also trying to address more than just getCasts(). I’m curious about the performance between what was implemented is vs that PR but that PR also doesn’t matter so much since it got rejected without any guidance on how to improve it. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Laravel Version
12.28
PHP Version
8.4.12
Database Driver & Version
mariadb 10.11.13
Description
I profiled a long running job with spx and got:
then I changed the HasAttributes::getCastType() from:
to:
The runtime of the job went from 21.7s to 17.7s (without spx).
Then I changed HasAttributes::isEncryptedCastable() from:
to:
The runtime of the job went from 17.7s to 15.1s (without spx). So a total of 30% faster.
What do you think about these changes?
Note: I can't provide a PR for this, but you and the community are free to create a PR for this.
Steps To Reproduce
Run a script using Eloquent with many Model instances.
Beta Was this translation helpful? Give feedback.
All reactions