Skip to main content

← HomOps Reference

HomMuxConv

Run a 2D convolution on a multiplexed feature map, with every channel and pixel in one ciphertext.

McML
RunsServer
Changes shapeYes - repacked to the output map
Changes scaleYes - packed kernel multiply
Levels spent1 - multiply and repack
RotatesYes - kernel taps and the re-interleave
KeysRotation key

What it does​

HomMuxConv is a 2D convolution (OP_TYPE.MuxConv) on a multiplexed packing. The whole feature map (C, H, W) sits in one ciphertext, and each pixel's C channels occupy adjacent slots. A deep CNN such as ResNet keeps one ciphertext per layer.
The parameters mirror PyTorch Conv2d. kernel_shape is (C_out, C_in, kh, kw), image_hw is the input's (H, W), and stride, padding, dilation, and an optional per-channel bias follow. The convolution is ungrouped: groups=1 only.
The output is written back in the same multiplexed layout, including when stride shrinks the map, so the next conv reads it directly. Conv + batch-norm pairs belong on HomMuxConvBnFused. A dense layer is a 1×1 conv on a 1×1 map. The ResNet-20 example is the end-to-end reference.
For the square packing that stores each channel across H*W slots, see HomConv.

Signature​

HomMuxConv(
    kernel_shape,                       # (C_out, C_in, kh, kw)
    image_hw,                           # (H, W)
    stride=(1, 1),                 # int | (h, w)
    padding=(0, 0),                # int | (h, w); None → same
    dilation=(1, 1),               # int | (h, w)
    with_modswitch=True,
    bias=False,
)
Kernel weights (and bias, when enabled) come in through set_data. weight.shape must equal kernel_shape.

Parameters​

ParameterTypeDefaultDescription
kernel_shape(C_out, C_in, kh, kw)requiredWeight shape. C_in is the full input channel count (groups=1).
image_hw(H, W)requiredSpatial size of the map the client packed.
strideint | (h, w)(1, 1)Normalized to a 2-tuple. A stride other than (1, 1) also re-interleaves the smaller output map.
paddingint | (h, w) | None(0, 0)Normalized to a 2-tuple. None selects same padding, (k - 1) // 2 on each side.
dilationint | (h, w)(1, 1)Spatial dilation of the kernel, as a scalar or (dh, dw).
with_modswitchboolTrueMod-switch after the packed multiply. The default spends one level and returns the scale to the input scale.
biasboolFalseIf True, set_data expects a 1D bias of length C_out. It is added on the real slots of that channel. Padding stays zero.
Rules that depend on the value
SettingRuleIf you break it
groupsUngrouped only. There is no groups argument; kernel_shape is (C_out, C_in, kh, kw).A grouped weight fails the shape check in set_data.
set_data (bias=False)set_data(weight) with weight.shape == kernel_shape.AssertionError.
set_data (bias=True)set_data(weight, bias). Bias is 1D with length C_out.AssertionError.
layout periodThe input period and the output period must each be ≤ the ring slot count n/2.Compilation fails: mux layouts must fit one ciphertext.

Packing the input​

The client encrypts one vector. Build it with MuxLayout from lattica_build.operators.ml.h_mux_conv. Channel c of pixel (i, j) lands at slot (i · W' + j) · C + c, where W' is W rounded up to a power of 2. Slots with no pixel or channel stay zero.
QuantityValue
H', W'next power of 2 ≥ H, and the same for W
Slots the map occupiesH' · W' · C
Periodthat product, rounded up to a power of 2. This is the block length.
Slot of channel c at (i, j)(i · W' + j) · C + c
A 16×32×32 map occupies 32 · 32 · 16 = 16384 slots. 16384 is already a power of 2, so the period is 16384. That period has to be ≤ n/2. The output period has to fit as well. MuxLayout defaults n_slots to the period; pass a larger power of two when the ciphertext block is bigger than the map. A block smaller than the slots the map occupies raises ValueError.
The first layer's C_in is the channel count the packing uses. Pad a narrower image with zero channels before pack. ResNet-20 pads 3 RGB channels to 16.
pack.pyPYTHON
from lattica_build.operators.ml.h_mux_conv import MuxLayout
import torch

C, H, W = 16, 32, 32
rgb = torch.randn(3, H, W)          # the clear image

# Pad channels with zeros up to the first conv's C_in.
# ResNet-20 pads RGB (3) to 16. Pad order is (W, H, C).
image = torch.nn.functional.pad(rgb, (0, 0, 0, 0, 0, C - 3))

layout = MuxLayout(C, H, W)       # n_slots defaults to the period, 16384
packed = layout.pack(image)         # encrypt this vector

Requirements​

  • set_data called. Weights must be attached before prep. When bias=True, pass the bias in the same call.
  • One ciphertext. period = next_pow2(next_pow2(H) · next_pow2(W) · C) for the input, and the same formula on (C_out, H_out, W_out), must each be ≤ n/2.
  • Client packing. Encrypt MuxLayout(C, H, W).pack(image), with image.shape == (C, H, W) and image_hw == (H, W).
  • groups=1. The kernel is (C_out, C_in, kh, kw).
  • Rotation key entries. The compiler adds them to the evaluation key. You don't list them yourself.
The fit check surfaces as a compilation error; see Compilation errors.

Shape effect - yes​

Rule: the output is one slot vector in the output mux layout. (H_out, W_out) is the usual Conv2d size. With a 3×3 kernel, stride 2, and padding 1, that is H/2 by W/2, which is the ResNet downsampling block. The conv re-interleaves C_out channels into that layout in the same step.
Chain convs directly. Set the next conv's image_hw to this conv's (H_out, W_out) and its C_in to this conv's C_out. The ciphertext metadata is the period of each layout:
Stagetensor shapen_slots
Packed input(period_in,)period of (C_in, H, W)
After HomMuxConv(period_out,)period of (C_out, H_out, W_out)

Scale effect - yes​

The packed kernel multiply updates scale the same way a plaintext multiply does. With the default with_modswitch=True, the mod-switch brings the scale back to the input scale. Bias add does not multiply scale. How scale moves through a pipeline is on Ciphertext state - Scale.

Level budget​

StepLevels spent
Kernel multiply and output repack1 when with_modswitch=True (the default)
Rotations0
Bias add0
The repack is part of that one level. It does not add another. How primes are organized in the chain is explained on Level budget - The chain.

Keys​

Rotation key. Needed for the kernel-tap alignments and the output re-interleave. Generated automatically. No square key: the encrypted operand is multiplied only by plaintexts.

Batch-norm fusion​

HomMuxConvBnFused is a HomMuxConv that folds batch-norm, and an optional scale for the next activation, into the conv weights and bias. Fusion happens at set_data time, so the encrypted conv still costs one level. Use it for Conv + BN pairs. It is not separately registered; it inherits OP_TYPE.MuxConv and always runs with bias=True. The fused bias leaves padding slots at zero.
W_fused = δ · γ · W / √(σ² + ε)
B_fused = δ · (β + γ · (B − μ) / √(σ² + ε))
δ is delta. Leave it at 1.0 when the next activation needs no extra scale. ResNet-20 sets it from the following ReLU.
HomMuxConvBnFused(
    kernel_shape,                       # (C_out, C_in, kh, kw)
    image_hw,                           # (H, W)
    stride=(1, 1),
    padding=(0, 0),                # int | (h, w); None → same
    dilation=(1, 1),
    with_modswitch=True,             # bias is always on
)
set_data
ParameterTypeDefaultDescription
weightTensorrequiredConv weight W. Shape matches kernel_shape.
biasTensor | NoneNone → zerosConv bias B.
meanTensorrequiredBN running mean μ.
varTensorrequiredBN running variance σ².
gammaTensor | NoneNone → onesBN scale γ.
betaTensor | NoneNone → zerosBN shift β.
epsfloat1e-5Stability constant ε.
deltafloat1.0Extra scale δ folded into the weight and the bias, for the next activation.
conv_bn.pyPYTHON
from lattica_build.operators.ml.h_mux_conv import HomMuxConvBnFused
import torch

C_in, C_out = 16, 16
conv_bn = HomMuxConvBnFused(
    kernel_shape=(C_out, C_in, 3, 3),
    image_hw=(32, 32),
    stride=1,
    padding=1,
)
conv_bn.set_data(
    weight=torch.randn(C_out, C_in, 3, 3),
    bias=torch.zeros(C_out),         # None → zeros
    mean=torch.zeros(C_out),
    var=torch.ones(C_out),
    gamma=torch.ones(C_out),         # None → ones
    beta=torch.zeros(C_out),         # None → zeros
    eps=1e-5,
    delta=1.0,                   # scale for the next activation
)

Fully connected layers​

A 1×1 conv on a 1×1 map is a linear layer. The ResNet-20 head sums over the pixels, then runs:
HomMuxConv(kernel_shape=(10, 64, 1, 1), image_hw=(1, 1), bias=True)
The output layout occupies 10 slots and its period is 16. Logit c lands at slot c. The sum has already added the spatial positions, so the averaging factor belongs in the weights.
head.pyPYTHON
from lattica_build.operators.ml.h_mux_conv import HomMuxConv
from lattica_build.operators.slots.h_sum_slots import HomSumSlots
import torch

channels = 64                       # ResNet-20's last map is (64, 8, 8)
spatial = 8 * 8
pool = HomSumSlots()             # one sum of the period (the pixels)
fc = HomMuxConv(
    kernel_shape=(10, channels, 1, 1),
    image_hw=(1, 1),
    bias=True,
)
weight = torch.randn(10, channels, 1, 1)
bias = torch.randn(10)
# SumSlots supplies a sum; fold the averaging factor into the weights.
fc.set_data(weight / spatial, bias)
# logit c lands at slot c

Example​

A biased 3×3 conv on a 32×32 map with 16 channels. The packed vector is the ciphertext the client sends; 16384 slots have to fit in n/2.
pipeline.pyPYTHON
from lattica_build.base_classes.hom_pipeline import HomomorphicPipeline
from lattica_build.operators.ml.h_mux_conv import HomMuxConv, MuxLayout
import torch

C_in, C_out, H, W = 16, 16, 32, 32
conv = HomMuxConv(
    kernel_shape=(C_out, C_in, 3, 3),
    image_hw=(H, W),
    stride=1,
    padding=1,
    bias=True,
)
conv.set_data(
    torch.randn(C_out, C_in, 3, 3),
    torch.randn(C_out),
)

layout = MuxLayout(C_in, H, W)
pipeline = HomomorphicPipeline(
    hom=conv,
    input_shape=(layout.period,),    # 16384 slots
)

See also​

  • HomConv - 2D conv on the square (C, H*W) packing
  • HomConvBnFused - the same BN fold on HomConv
  • HomSumSlots - the pixel sum in front of a 1×1 head
  • ResNet-20 - end-to-end multiplexed network: packing, fused conv + batch-norm, stride-2 blocks, and the 1×1 head