I noticed a consistent gas difference between abi.encodeCall and abi.encodeWithSelector when encoding identical function calls. I’d like to understand if this is an intentional optimization in the compiler or a side effect of different codegen paths.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.35;
interface IAuthority {
function canCall(
address caller,
address target,
bytes4 selector
) external view returns (bool allowed);
}
contract AbiEncodeCall {
function encode() external pure returns (bytes memory data) { // 1269 gas
data = abi.encodeCall(
IAuthority.canCall,
(address(0), address(0), bytes4(0xdeadbeaf))
);
}
}
contract AbiEncodeWithSelector {
function encode() external pure returns (bytes memory data) { // 1272 gas
data = abi.encodeWithSelector(
0xb7009613,
address(0),
address(0),
bytes4(0xdeadbeaf)
);
}
}
Does the compiler generate a different (more optimized) encoding path for abi.encodeCall since it has full type information at compile time?
Is abi.encodeWithSelector emitting extra masking/padding opcodes because argument types are unknown to the compiler?
Is this considered a known tradeoff, or would aligning the codegen be desirable?
This is a difference of 3 gas, which very inconsequential. I would not read too much into it. It’s not intentional and comes down to how successful the optimizer is in reducing slightly different but equivalent code into the same minimal bytecode. The difference also only exists in the evmasm pipeline. You get the same output in both cases via IR.
If you want to see what exactly happens here, I suggest looking at the differences in the --ir output. In this case, the part that matters is this:
The differences come down to how the constant is stored, cleanup and conversions.
encodeCall() uses the selector member, which is bytes4 and does not require any conversions. It’s directly stored as 4 bytes padded on the right to a full word. This is cheaper to execute, but also takes more space in the bytecode. encodeWithSelector() version gets an integer value. The integer is shorter, but needs to be shifted and cleaned. If you add an explicit cast to bytes4 in the encodeWithSelector() version, you’ll notice that the output becomes identical (with the only remaining IR differences being the names and metadata).
The reason this is inconsequential is that this is just what the codegen produces. The code then still goes through the optimizer, which may change the situation completely depending on your parameters. For example a low value of --optimize-runs may force the bytes4 constant to be deconstructed into a shorter one. And inlining may remove the cleanup (which is a no-op here anyway). The gas differences coming from that can easily overshadow the 3 gas. And the bytecode size is an obvious trade-off.