こんにちは
前回の記事ではOpenSea Market Contractについて書きましたが、その際にsolidityでもinline assemblyを使えることを知り、気になったのでOpenSeaのコードを例にinline assemblyについての知見をまとめようと思います。
(曖昧な部分もあるので、間違っているところがあればご教授ください。)
inline assemblyを使う意味
solidityでinline assemblyを使うメリットとして以下が挙げられると思います。
- コンパイラの制約を無視した実装ができる
- ガス代が浮く
- inline assemblyでしかできない操作を実現できる
OpenSeaのコードでは主に1が理由でinline assemblyが採用されています。
OpenSeaのコードでのinline assembly
実際にコードを見ていきます。以下のguardedArrayReplace(bytes memory array, bytes memory desired, bytes memory mask)
はarrayのbitでmaskが0のものをdesiredに置き換える関数です。
function guardedArrayReplace(bytes memory array, bytes memory desired, bytes memory mask)
internal
pure
{
require(array.length == desired.length);
require(array.length == mask.length);
uint words = array.length / 0x20;
uint index = words * 0x20;
assert(index / 0x20 == words);
uint i;
for (i = 0; i < words; i++) {
/* array[i] = (!mask[i] && array[i]) || (mask[i] && desired[i]) と同様の処理をbitwiseに行う */
assembly {
let commonIndex := mul(0x20, add(1, i))
let maskValue := mload(add(mask, commonIndex))
mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))
}
}
/* 最後の1ワードを処理 */
if (words > 0) {
/* 上と同様の処理 */
i = words;
assembly {
let commonIndex := mul(0x20, add(1, i))
let maskValue := mload(add(mask, commonIndex))
mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))
}
} else {
/* array.length < 32の場合、bytewiseで実装する必要がある。(理由は調査できていない) */
for (i = index; i < array.length; i++) {
array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i]);
}
}
}
inline assemblyが使われている理由
まず、なぜ array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i])
をinline assamblyで実現しているのかということですが、これはinline assemblyを使わないとcompileした時にboundary checkが挟まれてしまい、効率が悪くなるからです。つまり、array[i]にアクセスする度に、iはarray.lengthより小さいか(アクセスしようとしている要素は、確かにその配列に含まれているか)をチェックするようにcompileされてしまうのです。詳しく調べられていないのですが、もし配列の要素にアクセスする度にboundary checkが行われるのであれば、array[i] = ((mask[i] ^ 0xff) && array[i]) || (mask[i] && desired[i])
の中ではboundary checkが5回行われることになり、無駄になります。
ポイント
上のコードでいくつか気になったところがあるのでまとめます。
- 32byteを1ワードとして扱っている。
uint words = array.length / 0x20;
からわかるように32byteを1ワードとしています。solidityでは32byteを一つの区切り(slot)として扱うようで、mstoreやmloadは32byte単位でメモリの読み書きをします。
2. 配列のポインタには配列の長さが格納されている
let commonIndex := mul(0x20, add(1, i))
で、なぜadd(1, i)
とするのか疑問に思っていました。これでは配列の最初の要素を飛ばしてしまうのではないかと思ったのですが、違いました。例えばmload(array)
とするとarray.lengthが取り出されます。つまりarrayアドレスにはarray.lengthが格納されているのです。
3. 配列の一部が格納されているslotには、他に何も保存されない。
arrayを32byte単位で扱うのは分かったのですが、最後の(array.length % 32) byte分の要素(端数部分)はどう扱えば良いのでしょうか。32byte単位で扱うと、対象となる配列以外の領域も書き換えてしまうのではないかと思ったのですが、結論から言うと、他の場合と同様の処理を施せば良いです。
/* 最後の1ワードを処理 */
if (words > 0) {
/* 上と同様の処理 */
i = words;
assembly {
let commonIndex := mul(0x20, add(1, i))
let maskValue := mload(add(mask, commonIndex))
mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))
}
} else {
/* array.length < 32の場合、bytewiseで実装する必要がある。 */
for (i = index; i < array.length; i++) {
array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i]);
}
}
Layout of State Variables in Storageには以下のように書いてあります。
Structs and array data always start a new slot and occupy whole slots (but items inside a struct or array are packed tightly according to these rules).
つまり、配列は一つのslot (32byteの領域)を占有するため、32byte未満の配列でも32byteの領域をフルで使い、他の変数などの値は保存されません。
以上、solidityのinline assamblyで気になった点をまとめてみました。コードを書いていく上で気になったことがあれば、随時更新していこうと思います。
Suishow CTO 立川
最近のコメント