← 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_datacalled. Weights must be attached before prep. Whenbias=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), withimage.shape == (C, H, W)andimage_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_dataParameterTypeDefaultDescription
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