Row-packed sub-byte fully-connected weights (#23403) - #23403
mcremon-meta wants to merge 1 commit into
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/23403
Note: Links to docs will display an error until the docs builds have been completed. ✅ No FailuresAs of commit 0f6b8c8 with merge base 5e21c13 ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@mcremon-meta has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117289220. |
This PR needs a
|
Summary: Adds 4- and 6-bit row-packed weight storage for fully-connected layers, from the packing utilities all the way through to a dispatchable operator. Nothing is landed unreachable: the packer, the operator, the registration, the lowering hook and the backend fallback rows all arrive together. The operator is `quantized_fully_connected_packed` rather than a packed `quantized_linear` because wakeword stage 2 lowers to fully-connected, so this is the shape the model will actually hit. Layout: a [out_dim, in_dim] int8 weight becomes [out_dim, ceil(in_dim*bits/8)]. Packing per row is what keeps `out_dim` as `weight.size(0)`, so the existing `out_size[-1] = weight.size(0)` meta computation still holds. `in_dim` and `weight_bits` become explicit operator arguments, since neither can be read off the packed shape any more. 6-bit follows the torchao UINT6 byte order; signed values are stored as offset binary. Packing is storage-only and never changes a value. 6-bit means "quantize with quant_min/quant_max of -32/31, then store it in 6 bits", not "round an 8-bit weight down". A layer whose weights do not fit the narrower range, or whose in_dim is not a multiple of the group size, is skipped and stays 8-bit rather than failing the compile. Selection is an explicit compile-time option: a `weight_bits` field on `EdgePassesConfig`, defaulting to 8 (off). The transform runs in `apply_exir_ops_passes` after `.transform(...)` rather than as a pipeline pass, because it changes a constant's *shape* and so needs the ExportedProgram, not the GraphModule a pass is handed. Scalar qparams on a `.per_tensor` source are lifted to length-1 constants, since the packed operator only has the tensor-qparam form. The reference implementation unpacks and delegates to `quantized_linear_common` so the packed path cannot drift from the unpacked arithmetic. The C++ operator wraps the same inner kernel `op_quantized_fully_connected.cpp` uses. Backend wiring: only the generic row is added to `operator_fallback.bzl`. HiFi and vision resolve to it through the existing fallback chain, which is the point of the chain - a backend gets a row only when it has a specialized implementation. Verified by removing the redundant HiFi row and confirming the wakeword build still resolves the operator. HiFi's CMake build lists generic fallback sources explicitly, so the new source is added there. Jarvis keeps its own operator schema registry in `min_runtime/custom_ops.yaml` which `codegen.py` reads instead of the cadence yamls; a cadence op missing from it fails the runtime build with a bare KeyError, so it is declared there too. The requant expression in the packed kernel is deliberately byte-for-byte identical to `quantized_linear.h`, including the `(1 << 31)`, because the tests assert the packed and dense kernels agree exactly. Differential Revision: D117289220
357e815 to
d03c21f
Compare
Summary: Adds 4- and 6-bit row-packed weight storage for fully-connected layers, from the packing utilities all the way through to a dispatchable operator. Nothing is landed unreachable: the packer, the operator, the registration, the lowering hook and the backend fallback rows all arrive together. The operator is `quantized_fully_connected_packed` rather than a packed `quantized_linear` because wakeword stage 2 lowers to fully-connected, so this is the shape the model will actually hit. Layout: a [out_dim, in_dim] int8 weight becomes [out_dim, ceil(in_dim*bits/8)]. Packing per row is what keeps `out_dim` as `weight.size(0)`, so the existing `out_size[-1] = weight.size(0)` meta computation still holds. `in_dim` and `weight_bits` become explicit operator arguments, since neither can be read off the packed shape any more. 6-bit follows the torchao UINT6 byte order; signed values are stored as offset binary. Packing is storage-only and never changes a value. 6-bit means "quantize with quant_min/quant_max of -32/31, then store it in 6 bits", not "round an 8-bit weight down". A layer whose weights do not fit the narrower range, or whose in_dim is not a multiple of the group size, is skipped and stays 8-bit rather than failing the compile. Selection is an explicit compile-time option: a `weight_bits` field on `EdgePassesConfig`, defaulting to 8 (off). The transform runs in `apply_exir_ops_passes` after `.transform(...)` rather than as a pipeline pass, because it changes a constant's *shape* and so needs the ExportedProgram, not the GraphModule a pass is handed. Scalar qparams on a `.per_tensor` source are lifted to length-1 constants, since the packed operator only has the tensor-qparam form. The reference implementation unpacks and delegates to `quantized_linear_common` so the packed path cannot drift from the unpacked arithmetic. The C++ operator wraps the same inner kernel `op_quantized_fully_connected.cpp` uses. Backend wiring: only the generic row is added to `operator_fallback.bzl`. HiFi and vision resolve to it through the existing fallback chain, which is the point of the chain - a backend gets a row only when it has a specialized implementation. Verified by removing the redundant HiFi row and confirming the wakeword build still resolves the operator. HiFi's CMake build lists generic fallback sources explicitly, so the new source is added there. Jarvis keeps its own operator schema registry in `min_runtime/custom_ops.yaml` which `codegen.py` reads instead of the cadence yamls; a cadence op missing from it fails the runtime build with a bare KeyError, so it is declared there too. The requant expression in the packed kernel is deliberately byte-for-byte identical to `quantized_linear.h`, including the `(1 << 31)`, because the tests assert the packed and dense kernels agree exactly. Differential Revision: D117289220
d03c21f to
ed5cffa
Compare
Summary: Pull Request resolved: #23403 Adds 4- and 6-bit row-packed weight storage for fully-connected layers, from the packing utilities all the way through to a dispatchable operator. Nothing is landed unreachable: the packer, the operator, the registration, the lowering hook and the backend fallback rows all arrive together. The operator is `quantized_fully_connected_packed` rather than a packed `quantized_linear` because wakeword stage 2 lowers to fully-connected, so this is the shape the model will actually hit. Layout: a [out_dim, in_dim] int8 weight becomes [out_dim, ceil(in_dim*bits/8)]. Packing per row is what keeps `out_dim` as `weight.size(0)`, so the existing `out_size[-1] = weight.size(0)` meta computation still holds. `in_dim` and `weight_bits` become explicit operator arguments, since neither can be read off the packed shape any more. 6-bit follows the torchao UINT6 byte order; signed values are stored as offset binary. Packing is storage-only and never changes a value. 6-bit means "quantize with quant_min/quant_max of -32/31, then store it in 6 bits", not "round an 8-bit weight down". A layer whose weights do not fit the narrower range, or whose in_dim is not a multiple of the group size, is skipped and stays 8-bit rather than failing the compile. Selection is an explicit compile-time option: a `weight_bits` field on `EdgePassesConfig`, defaulting to 8 (off). The transform runs in `apply_exir_ops_passes` after `.transform(...)` rather than as a pipeline pass, because it changes a constant's *shape* and so needs the ExportedProgram, not the GraphModule a pass is handed. Scalar qparams on a `.per_tensor` source are lifted to length-1 constants, since the packed operator only has the tensor-qparam form. The reference implementation unpacks and delegates to `quantized_linear_common` so the packed path cannot drift from the unpacked arithmetic. The C++ operator wraps the same inner kernel `op_quantized_fully_connected.cpp` uses. Backend wiring: only the generic row is added to `operator_fallback.bzl`. HiFi and vision resolve to it through the existing fallback chain, which is the point of the chain - a backend gets a row only when it has a specialized implementation. Verified by removing the redundant HiFi row and confirming the wakeword build still resolves the operator. HiFi's CMake build lists generic fallback sources explicitly, so the new source is added there. Jarvis keeps its own operator schema registry in `min_runtime/custom_ops.yaml` which `codegen.py` reads instead of the cadence yamls; a cadence op missing from it fails the runtime build with a bare KeyError, so it is declared there too. The requant expression in the packed kernel is deliberately byte-for-byte identical to `quantized_linear.h`, including the `(1 << 31)`, because the tests assert the packed and dense kernels agree exactly. Differential Revision: D117289220
ed5cffa to
0f6b8c8
Compare
Summary:
Adds 4- and 6-bit row-packed weight storage for fully-connected layers, from
the packing utilities all the way through to a dispatchable operator. Nothing
is landed unreachable: the packer, the operator, the registration, the
lowering hook and the backend fallback rows all arrive together.
The operator is
quantized_fully_connected_packedrather than a packedquantized_linearbecause wakeword stage 2 lowers to fully-connected, so thisis the shape the model will actually hit.
Layout: a [out_dim, in_dim] int8 weight becomes [out_dim, ceil(in_dim*bits/8)].
Packing per row is what keeps
out_dimasweight.size(0), so the existingout_size[-1] = weight.size(0)meta computation still holds.in_dimandweight_bitsbecome explicit operator arguments, since neither can be read offthe packed shape any more. 6-bit follows the torchao UINT6 byte order; signed
values are stored as offset binary.
Packing is storage-only and never changes a value. 6-bit means "quantize with
quant_min/quant_max of -32/31, then store it in 6 bits", not "round an 8-bit
weight down". A layer whose weights do not fit the narrower range, or whose
in_dim is not a multiple of the group size, is skipped and stays 8-bit rather
than failing the compile.
Selection is an explicit compile-time option: a
weight_bitsfield onEdgePassesConfig, defaulting to 8 (off). The transform runs inapply_exir_ops_passesafter.transform(...)rather than as a pipeline pass,because it changes a constant's shape and so needs the ExportedProgram, not
the GraphModule a pass is handed. Scalar qparams on a
.per_tensorsource arelifted to length-1 constants, since the packed operator only has the
tensor-qparam form.
The reference implementation unpacks and delegates to
quantized_linear_commonso the packed path cannot drift from the unpacked arithmetic. The C++ operator
wraps the same inner kernel
op_quantized_fully_connected.cppuses.Backend wiring: only the generic row is added to
operator_fallback.bzl. HiFiand vision resolve to it through the existing fallback chain, which is the
point of the chain - a backend gets a row only when it has a specialized
implementation. Verified by removing the redundant HiFi row and confirming the
wakeword build still resolves the operator. HiFi's CMake build lists generic
fallback sources explicitly, so the new source is added there. Jarvis keeps its
own operator schema registry in
min_runtime/custom_ops.yamlwhichcodegen.pyreads instead of the cadence yamls; a cadence op missing from itfails the runtime build with a bare KeyError, so it is declared there too.
The requant expression in the packed kernel is deliberately byte-for-byte
identical to
quantized_linear.h, including the(1 << 31), because the testsassert the packed and dense kernels agree exactly.
Differential Revision: D117289220