You don't need assembly, you can do it with Solidity.
Be careful, sometimes it is better to use uint256
anyway because of conversion costs and more complexity, which generate more costs than the savings. Always check your real costs.
Said that, see the example below for two techniques you can use to save some gas.
You can try the code by yourself using this repo I created ad hoc to support this kind of question.
// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.9.0;
struct PackedUint16 { uint256 word; }
library packedUint16 {
function set(PackedUint16 storage self, uint16 value, uint8 index)
public
{
self.word = self.word | uint(value) << index * 16;
}
function get(PackedUint16 storage self, uint8 index)
public
view
returns (uint16)
{
return uint16(self.word >> (index * 16));
}
}
contract pack {
//
// Mid costs (28.901 gas)
//
using packedUint16 for PackedUint16;
PackedUint16 public myValue;
function testPackedUint16()
public
{
myValue.set(10, 0);
myValue.set(100, 1);
myValue.set(1000, 2);
myValue.set(10000, 3);
}
function readPackedUint16()
public
view
returns (uint16, uint16, uint16, uint16)
{
return (myValue.get(0), myValue.get(1), myValue.get(2), myValue.get(3));
}
//
// The most costly (88.443 gas)
//
uint public a;
uint public b;
uint public c;
uint public d;
function testUint256()
public
{
a = uint(10);
b = uint(100);
c = uint(1000);
d = uint(10000);
}
//
// Cheapest one (22.134 gas)
//
uint public packed;
function testPacked()
public
{
packed |= uint(10) << 0 * 16;
packed |= uint(100) << 1 * 16;
packed |= uint(1000) << 2 * 16;
packed |= uint(10000) << 3 * 16;
}
function readPacked()
public
view
returns (uint16, uint16, uint16, uint16)
{
return (uint16(packed >> 0), uint16(packed >> 16), uint16(packed >> 32), uint16(packed >> 48));
}
}