Skip to content

Commit 8821b08

Browse files
authored
fix: vectorize 3-bit and 6-bit bitstream packing (~1900× speedup)
Vectorize _pack_bitstream/_unpack_bitstream with NumPy broadcasting, eliminating O(n) Python loop overhead. Measured ~1900× speedup for bits=3 at n=131072 elements. All 787 tests pass.
1 parent be9f697 commit 8821b08

1 file changed

Lines changed: 56 additions & 45 deletions

File tree

src/tqai/packing.py

Lines changed: 56 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -160,75 +160,86 @@ def _unpack_4bit(packed: np.ndarray, n: int) -> np.ndarray:
160160

161161

162162
def _pack_bitstream(flat: np.ndarray, bits: int) -> np.ndarray:
163-
"""General bit-stream packing for non-byte-aligned widths (3, 6 bits)."""
163+
"""Vectorized bit-stream packing for non-byte-aligned widths (3, 6 bits).
164+
165+
Strategy: every LCM(bits, 8) bits forms a self-contained block.
166+
bits=3 → LCM=24 → 8 indices × 3-bit = 3 bytes per block
167+
bits=6 → LCM=24 → 4 indices × 6-bit = 3 bytes per block
168+
All full blocks are handled with a single NumPy reduction; the
169+
tail (< block_indices elements) is handled with a tiny Python loop.
170+
"""
164171
n = len(flat)
165172
n_bytes = (n * bits + 7) // 8
166-
out = np.zeros(n_bytes, dtype=np.uint8)
167173

168-
# Vectorize using uint64 accumulators: process indices in blocks that
169-
# fill an exact number of bytes.
170-
# LCM(bits, 8) / bits = indices per block; LCM(bits, 8) / 8 = bytes per block
171174
lcm = _lcm(bits, 8)
172-
block_indices = lcm // bits # e.g. bits=3 → lcm=24 → 8 indices per block
173-
block_bytes = lcm // 8 # e.g. bits=3 → 3 bytes per block
175+
block_indices = lcm // bits # indices per block (8 for 3-bit, 4 for 6-bit)
176+
block_bytes = lcm // 8 # bytes per block (3 for both)
174177

175-
n_full_blocks = n // block_indices
178+
n_full = n // block_indices
176179
remainder = n % block_indices
177180

178-
if n_full_blocks > 0:
179-
blocks = flat[: n_full_blocks * block_indices].reshape(n_full_blocks, block_indices)
180-
for b_idx in range(n_full_blocks):
181-
acc: int = 0
182-
for i in range(block_indices):
183-
acc |= int(blocks[b_idx, i]) << (i * bits)
184-
base = b_idx * block_bytes
185-
for j in range(block_bytes):
186-
out[base + j] = (acc >> (j * 8)) & 0xFF
187-
188-
# Handle remainder indices
181+
# Per-position bit shifts within a block.
182+
# All accumulators fit in uint32: max value = 2^LCM(bits,8) - 1 ≤ 2^24 - 1.
183+
idx_shifts = (np.arange(block_indices, dtype=np.uint32) * bits)
184+
byte_shifts = (np.arange(block_bytes, dtype=np.uint32) * 8)
185+
186+
out = np.zeros(n_bytes, dtype=np.uint8)
187+
188+
if n_full > 0:
189+
blocks = flat[: n_full * block_indices].reshape(n_full, block_indices).astype(np.uint32)
190+
acc = (blocks << idx_shifts).sum(axis=1, dtype=np.uint32) # (n_full,)
191+
bytes_2d = ((acc[:, None] >> byte_shifts) & 0xFF).astype(np.uint8) # (n_full, block_bytes)
192+
out[: n_full * block_bytes] = bytes_2d.ravel()
193+
189194
if remainder:
190-
acc = 0
195+
acc_r = 0
196+
base_idx = n_full * block_indices
191197
for i in range(remainder):
192-
acc |= int(flat[n_full_blocks * block_indices + i]) << (i * bits)
193-
base = n_full_blocks * block_bytes
194-
remaining_bits = remainder * bits
195-
remaining_bytes = (remaining_bits + 7) // 8
196-
for j in range(remaining_bytes):
197-
out[base + j] = (acc >> (j * 8)) & 0xFF
198+
acc_r |= int(flat[base_idx + i]) << (i * bits)
199+
base_out = n_full * block_bytes
200+
rem_bytes = (remainder * bits + 7) // 8
201+
for j in range(rem_bytes):
202+
out[base_out + j] = (acc_r >> (j * 8)) & 0xFF
198203

199204
return out
200205

201206

202207
def _unpack_bitstream(packed: np.ndarray, bits: int, n: int) -> np.ndarray:
203-
"""General bit-stream unpacking for non-byte-aligned widths (3, 6 bits)."""
204-
out = np.zeros(n, dtype=np.uint8)
205-
mask = (1 << bits) - 1
208+
"""Vectorized bit-stream unpacking for non-byte-aligned widths (3, 6 bits).
206209
210+
Inverse of _pack_bitstream: reassemble uint32 accumulators per block via
211+
NumPy reduction, then extract each index with a right-shift + mask.
212+
"""
207213
lcm = _lcm(bits, 8)
208214
block_indices = lcm // bits
209215
block_bytes = lcm // 8
210216

211-
n_full_blocks = n // block_indices
217+
n_full = n // block_indices
212218
remainder = n % block_indices
219+
mask = np.uint32((1 << bits) - 1)
220+
221+
idx_shifts = (np.arange(block_indices, dtype=np.uint32) * bits)
222+
byte_shifts = (np.arange(block_bytes, dtype=np.uint32) * 8)
223+
224+
out = np.zeros(n, dtype=np.uint8)
213225

214-
if n_full_blocks > 0:
215-
for b_idx in range(n_full_blocks):
216-
base = b_idx * block_bytes
217-
acc = 0
218-
for j in range(block_bytes):
219-
acc |= int(packed[base + j]) << (j * 8)
220-
for i in range(block_indices):
221-
out[b_idx * block_indices + i] = (acc >> (i * bits)) & mask
226+
if n_full > 0:
227+
pb = packed[: n_full * block_bytes].reshape(n_full, block_bytes).astype(np.uint32)
228+
acc = (pb << byte_shifts).sum(axis=1, dtype=np.uint32) # (n_full,)
229+
indices_2d = ((acc[:, None] >> idx_shifts) & mask).astype(np.uint8) # (n_full, block_indices)
230+
out[: n_full * block_indices] = indices_2d.ravel()
222231

223232
if remainder:
224-
base = n_full_blocks * block_bytes
225-
remaining_bytes = ((remainder * bits) + 7) // 8
226-
acc = 0
227-
for j in range(remaining_bytes):
228-
if base + j < len(packed):
229-
acc |= int(packed[base + j]) << (j * 8)
233+
base_in = n_full * block_bytes
234+
rem_bytes = (remainder * bits + 7) // 8
235+
acc_r = 0
236+
for j in range(rem_bytes):
237+
if base_in + j < len(packed):
238+
acc_r |= int(packed[base_in + j]) << (j * 8)
239+
base_out = n_full * block_indices
240+
mask_int = (1 << bits) - 1
230241
for i in range(remainder):
231-
out[n_full_blocks * block_indices + i] = (acc >> (i * bits)) & mask
242+
out[base_out + i] = (acc_r >> (i * bits)) & mask_int
232243

233244
return out
234245

0 commit comments

Comments
 (0)