Deep EVM #7: Bucles y Condicionales Eficientes en Gas en Yul
Engineering Team
Flujo de control en Yul
Yul proporciona tres construcciones de flujo de control: if, switch, y for. Cada una compila a diferentes patrones de opcodes JUMP y JUMPI, con implicaciones distintas en el coste de gas.
El condicional if
El if en Yul no tiene else. Para bifurcaciones completas, usa switch:
assembly {
let x := calldataload(0)
// If simple — sin else
if gt(x, 100) {
x := 100 // Cap en 100
}
// Para if/else, usa switch
switch gt(x, 50)
case 1 {
// x > 50
mstore(0x00, 1)
}
default {
// x <= 50
mstore(0x00, 0)
}
}
Switch/case para despacho de funciones
El switch es especialmente útil para despacho de funciones, que es el patrón más común en contratos Yul standalone:
switch shr(224, calldataload(0))
case 0x70a08231 /* balanceOf */ {
returnUint(sload(mappingSlot(calldataload(4), 0)))
}
case 0xa9059cbb /* transfer */ {
ejecutarTransfer(calldataload(4), calldataload(36))
}
case 0x18160ddd /* totalSupply */ {
returnUint(sload(0))
}
default {
revert(0, 0)
}
Cada case compila a un PUSH4 + EQ + PUSH2 + JUMPI. Con N funciones, el peor caso es N comparaciones. Para contratos con muchas funciones, esto es O(N) — las jump tables de Huff son más eficientes.
Bucles for
El bucle for en Yul tiene la misma estructura que en C:
assembly {
// for (init; cond; post) { body }
for { let i := 0 } lt(i, 10) { i := add(i, 1) } {
// cuerpo del bucle
mstore(add(0x80, mul(i, 32)), i)
}
}
Optimización: incremento pre vs post
En Solidity, ++i es más barato que i++ porque evita una copia temporal. En Yul, esto no importa — add(i, 1) es todo lo que hay.
Optimización: cachear la longitud
// Malo: lee array.length de storage en cada iteración
for { let i := 0 } lt(i, sload(lengthSlot)) { i := add(i, 1) } {
// sload cada iteración = 100 gas (caliente) * N
}
// Bueno: cachear en variable local
let len := sload(lengthSlot) // 2100 gas una vez (frío)
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
// lt usa variable de stack = 3 gas * N
}
Iteración sobre arrays de storage
Los arrays dinámicos en Solidity almacenan la longitud en el slot base y los elementos a partir de keccak256(slot):
assembly {
// Array dinámico en slot 2
let slotBase := 2
let length := sload(slotBase)
// Los elementos comienzan en keccak256(slotBase)
mstore(0x00, slotBase)
let dataSlot := keccak256(0x00, 0x20)
let suma := 0
for { let i := 0 } lt(i, length) { i := add(i, 1) } {
suma := add(suma, sload(add(dataSlot, i)))
}
mstore(0x00, suma)
return(0x00, 0x20)
}
Iteración sobre calldata
Calldata es la fuente de datos más barata para iterar:
assembly {
// Función: sumarArray(uint256[] calldata nums)
// Calldata layout: [selector(4)] [offset(32)] [length(32)] [elem0(32)] [elem1(32)] ...
let offset := add(calldataload(4), 4) // Offset del array
let length := calldataload(offset)
let dataStart := add(offset, 32)
let suma := 0
for { let i := 0 } lt(i, length) { i := add(i, 1) } {
suma := add(suma, calldataload(add(dataStart, mul(i, 32))))
}
mstore(0x00, suma)
return(0x00, 0x20)
}
Cada calldataload cuesta solo 3 gas, comparado con 100+ gas para sload caliente.
Desenrollado de bucles
Para bucles con conteo conocido y pequeño, el desenrollado manual puede ahorrar el overhead del condicional y el incremento:
// Bucle normal: 5 iteraciones con overhead por iteración
for { let i := 0 } lt(i, 5) { i := add(i, 1) } {
// lt(3) + add(3) + jumpi(10) = 16 gas overhead por iteración
proceso(sload(add(base, i)))
}
// Desenrollado: sin overhead de bucle
proceso(sload(base))
proceso(sload(add(base, 1)))
proceso(sload(add(base, 2)))
proceso(sload(add(base, 3)))
proceso(sload(add(base, 4)))
El desenrollado ahorra ~16 gas por iteración eliminada, pero aumenta el tamaño del bytecode. Úsalo solo cuando el conteo de iteraciones es fijo y pequeño (< 8).
Patrones avanzados
Búsqueda binaria en array ordenado
function busquedaBinaria(base, len, objetivo) -> encontrado, indice {
let bajo := 0
let alto := len
for {} lt(bajo, alto) {} {
let medio := shr(1, add(bajo, alto))
let valor := sload(add(base, medio))
switch lt(valor, objetivo)
case 1 { bajo := add(medio, 1) }
default {
switch gt(valor, objetivo)
case 1 { alto := medio }
default {
encontrado := 1
indice := medio
bajo := alto // Salir del bucle
}
}
}
}
Conclusión
El flujo de control eficiente en Yul se reduce a minimizar opcodes JUMP (8 gas) y JUMPI (10 gas), cachear valores del storage, y elegir la fuente de datos correcta (calldata > memory > storage). Dominar estos patrones es fundamental para escribir contratos Yul de alto rendimiento.