[ROCM] [Communication] Add INT3 quantization method for quickreduce (#45666)

Signed-off-by: Haoyang Li <lihaoyang0109@gmail.com>

haoyangli0109 committed Jun 25, 2026 at 23:14 UTC 1744adc256b81b8a7d2371a5e21f88174d60cc93
7 files changed +264 -20
csrc/custom_quickreduce.cu
+10
@@ -97,18 +97,28 @@ int64_t qr_max_size() {
97 cast_bf2half>; \
98 template struct quickreduce::AllReduceTwoshot<T, Codec<T, 8>, cast_bf2half>;
99
100 + // INT3 (CodecQ3) is restricted to TP2 only, so we only instantiate the
101 + // world_size == 2 kernel for it.
102 + #define INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(T, Codec, cast_bf2half) \
103 + template struct quickreduce::AllReduceTwoshot<T, Codec<T, 2>, cast_bf2half>;
104 +
105 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecFP, false)
106 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ4, false)
107 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ6, false)
108 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ8, false)
109 +INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(quickreduce::nv_bfloat16,
110 + quickreduce::CodecQ3, false)
111 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecFP, true)
112 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ4, true)
113 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ6, true)
114 INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ8, true)
115 +INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(quickreduce::nv_bfloat16,
116 + quickreduce::CodecQ3, true)
117
118 INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecFP, false)
119 INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecQ4, false)
120 INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecQ6, false)
121 INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecQ8, false)
122 +INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(half, quickreduce::CodecQ3, false)
123
124 #endif // USE_ROCM
\ No newline at end of file
csrc/quickreduce/base.h
+23
@@ -283,6 +283,29 @@ __quickreduce_device_inline__ int packed_rcp<nv_bfloat16>(int a) {
283 return R.i;
284 }
285
286 +template <typename T>
287 +__quickreduce_device_inline__ int packed_from_int16_pair(int16_t low,
288 + int16_t high);
289 +
290 +template <>
291 +__quickreduce_device_inline__ int packed_from_int16_pair<half>(int16_t low,
292 + int16_t high) {
293 + // Convert two signed integers to one fp16x2 packed 32-bit lane.
294 + half2 h = __halves2half2(__int2half_rn(static_cast<int>(low)),
295 + __int2half_rn(static_cast<int>(high)));
296 + return __builtin_bit_cast(int, h);
297 +}
298 +
299 +template <>
300 +__quickreduce_device_inline__ int packed_from_int16_pair<nv_bfloat16>(
301 + int16_t low, int16_t high) {
302 + // Convert two signed integers to one bf16x2 packed 32-bit lane.
303 + nv_bfloat16 bf_low = __float2bfloat16(static_cast<float>(low));
304 + nv_bfloat16 bf_high = __float2bfloat16(static_cast<float>(high));
305 + nv_bfloat162 bf2 = __halves2bfloat162(bf_low, bf_high);
306 + return *reinterpret_cast<int*>(&bf2);
307 +}
308 +
309 // changes dtype
310 __quickreduce_device_inline__ float T2float_cast(half a) {
311 return __half2float(a);
csrc/quickreduce/quick_reduce.h
+26 -4
@@ -59,11 +59,30 @@ allreduce_prototype_twoshot(T const* A, T* B, uint32_t N, uint32_t num_blocks,
59 flag_color, this->kMaxProblemSize); \
60 }
61
62 +// INT3 only retains good performance on TP2 (world_size == 2). On TP4/TP8
63 +// the 3-bit codec's pack/unpack overhead outweighs the reduced communication
64 +// volume, so INT3 is restricted to a TP2-only dispatch here.
65 +#define TWOSHOT_DISPATCH_TP2_ONLY(__codec) \
66 + if (world_size == 2) { \
67 + using LineCodec = __codec<T, 2>; \
68 + using AllReduceKernel = AllReduceTwoshot<T, LineCodec, cast_bf2half>; \
69 + hipLaunchKernelGGL((allreduce_prototype_twoshot<AllReduceKernel, T>), \
70 + dim3(grid), dim3(kBlockTwoShot), 0, stream, A, B, N, \
71 + num_blocks, rank, dbuffer_list, data_offset, \
72 + flag_color, this->kMaxProblemSize); \
73 + } else { \
74 + throw std::runtime_error( \
75 + "INT3 quick all-reduce is only supported for world_size == 2 " \
76 + "(TP2); use INT4/NONE for larger world sizes."); \
77 + }
78 +
79 enum QuickReduceQuantLevel {
63 - F16 = 0,
64 - INT8 = 1,
65 - INT6 = 2,
66 - INT4 = 3,
80 + // Keep these ids in sync with Python QuickReduceRegime enum.
81 + F16 = 0, // full-precision fp16/bf16 communication
82 + INT8 = 1, // symmetric int8 + per-block scale
83 + INT6 = 2, // symmetric int6 + per-block scale
84 + INT4 = 3, // symmetric int4 + per-block scale
85 + INT3 = 4, // symmetric int3 + per-block scale (TP2 only)
86 };
87
88 struct DeviceComms {
@@ -184,6 +203,9 @@ struct DeviceComms {
203 case QuickReduceQuantLevel::INT4:
204 TWOSHOT_DISPATCH(CodecQ4)
205 break;
206 + case QuickReduceQuantLevel::INT3:
207 + TWOSHOT_DISPATCH_TP2_ONLY(CodecQ3)
208 + break;
209 default:
210 TWOSHOT_DISPATCH(CodecFP)
211 break;
csrc/quickreduce/quick_reduce_impl.cuh
+163 -1
@@ -2,6 +2,7 @@
2
3 #include <hip/hip_runtime.h>
4 #include "base.h"
5 +#include <hip/hip_fp16.h>
6
7 namespace quickreduce {
8
@@ -206,6 +207,168 @@ struct CodecQ4 : public CodecBase {
207 }
208 };
209
210 +// Int3 symmetric quantization codec.
211 +// We quantize the FP16 data to block-scaled Int3 in blocks of 4 *
212 +// kThreadGroupSize. Uniform symmetric quantization (round-to-int + clip),
213 +// matching the structure of CodecQ4. Signed range is [-4, +3].
214 +template <typename T, int world_size>
215 +struct CodecQ3 : public CodecBase {
216 + static constexpr int kWorldSize = world_size;
217 +
218 + // Layout per quantization block (32 values = 8 threads * 4 fp16x2 lanes):
219 + // - each thread owns 8 values and writes:
220 + // * q2 payload : 8 * 2 bits -> uint16 (2 bytes)
221 + // * q1 payload : 8 * 1 bit -> uint8 (1 byte)
222 + // - one scale is shared per 32 values and written by group leader.
223 + //
224 + // kRankTileStride is split as:
225 + // [0 .. 511] : q2 payload region (256 threads * 2 bytes)
226 + // [512 .. 767] : q1 payload region (256 threads * 1 byte)
227 + // [768 .. 895] : scale region (32 groups * 4 bytes)
228 + static constexpr int kRankAtoms = kAtoms / kWorldSize;
229 + static constexpr int kRankTileStride = 896;
230 + static constexpr int kRankTileQ1Offset = 512;
231 + static constexpr int kRankTileScaleOffset = 768;
232 + static constexpr int kRankTransmittedTileSize = kRankTileStride * kRankAtoms;
233 + static_assert(kRankTransmittedTileSize % 16 == 0,
234 + "kRankTransmittedTileSize must be 16B aligned.");
235 +
236 + static constexpr int kRankBufferTileStride =
237 + kRankTileStride / sizeof(int32x4_t);
238 +
239 + static constexpr int kTransmittedTileSize =
240 + kRankTransmittedTileSize * kWorldSize;
241 +
242 + // {-1/4.0h, -1/4.0h}, f16x2_t / bf16x2_t. Sign-flipped so absmax maps
243 + // to -4; the sign cancels with decoding_scale on the recv side.
244 + static constexpr int kScaleFactor =
245 + std::is_same<T, half>::value ? 0xB400B400 : 0xBE80BE80;
246 +
247 + // {1e-7, 1e-7}, f16x2_t
248 + static constexpr int kScaleEpsilon =
249 + std::is_same<T, half>::value ? 0x00010001 : 0x33D733D7;
250 +
251 + // {-4, -4}, f16x2_t / bf16x2_t
252 + static constexpr int kRangeMin =
253 + std::is_same<T, half>::value ? 0xC400C400 : 0xC080C080;
254 +
255 + // {+3, +3}, f16x2_t / bf16x2_t
256 + static constexpr int kRangeMax =
257 + std::is_same<T, half>::value ? 0x42004200 : 0x40404040;
258 +
259 + // {+4, +4}, int16x2_t -- shifts signed [-4, +3] to unsigned [0, 7].
260 + static constexpr int kRangeBias = 0x00040004;
261 +
262 + __quickreduce_device_inline__ CodecQ3(int thread, int rank)
263 + : CodecBase(thread, rank) {}
264 +
265 + __quickreduce_device_inline__ void send(int32x4_t* __restrict__ send_buffer,
266 + const int32x4_t* __restrict__ data) {
267 + for (int k = 0; k < kRankAtoms; k++) {
268 + int32x4_t const atom = data[k];
269 +
270 + // 1) Per-group dynamic scale (shared across 32 values).
271 + int wblockmax = group_abs_max<T>(atom);
272 + int decoding_scale = packed_mul<T>(wblockmax, kScaleFactor);
273 + int encoding_scale = packed_add<T>(decoding_scale, kScaleEpsilon);
274 + encoding_scale = packed_rcp<T>(encoding_scale);
275 +
276 + // 2) Scale + clip to signed int3 range [-4, +3].
277 + int32x4_t w;
278 + for (int i = 0; i < 4; i++) {
279 + w[i] = packed_mul<T>(atom[i], encoding_scale);
280 + w[i] = packed_max<T>(w[i], kRangeMin);
281 + w[i] = packed_min<T>(w[i], kRangeMax);
282 + }
283 +
284 + // 3) Round to integer and bias to unsigned domain [0, 7].
285 + int32x4_t q;
286 + {
287 + int16_t* qi = reinterpret_cast<int16_t*>(&q);
288 + T* wh = reinterpret_cast<T*>(&w);
289 + for (int i = 0; i < 8; i++) qi[i] = (int16_t)rintf(T2float_cast(wh[i]));
290 +
291 + for (int i = 0; i < 4; i++) {
292 + q[i] = packed_add<int16_t>(q[i], kRangeBias);
293 + }
294 + }
295 +
296 + // 4) Split each 3-bit unsigned value into low-2-bit and high-1-bit
297 + // halves, packed into one uint16 (low 2 bits per value) plus one
298 + // uint8 (high 1 bit per value).
299 + uint16_t q2w = 0;
300 + uint8_t q1w = 0;
301 + {
302 + int16_t* tw = reinterpret_cast<int16_t*>(&q);
303 +#pragma unroll
304 + for (int i = 0; i < 8; i++) {
305 + uint32_t v = static_cast<uint32_t>(tw[i]) & 0x7u;
306 + q2w |= static_cast<uint16_t>((v & 0x3u) << (i * 2));
307 + q1w |= static_cast<uint8_t>(((v >> 2) & 0x1u) << i);
308 + }
309 + }
310 +
311 + uint8_t* atom_ptr =
312 + reinterpret_cast<uint8_t*>(send_buffer + k * kRankBufferTileStride);
313 + uint16_t* q2w_ptr = reinterpret_cast<uint16_t*>(atom_ptr) + thread;
314 + uint8_t* q1w_ptr =
315 + reinterpret_cast<uint8_t*>(atom_ptr + kRankTileQ1Offset) + thread;
316 + int* qs_ptr = reinterpret_cast<int*>(atom_ptr + kRankTileScaleOffset) +
317 + (thread / 8);
318 +
319 + __builtin_nontemporal_store(q2w, q2w_ptr);
320 + *q1w_ptr = q1w;
321 + if (threadIdx.x == group_leader) {
322 + __builtin_nontemporal_store(decoding_scale, qs_ptr);
323 + }
324 + }
325 + }
326 +
327 + __quickreduce_device_inline__ void recv(int32x4_t** __restrict__ recv_buffer,
328 + int32x4_t* __restrict__ data) {
329 + for (int k = 0; k < kRankAtoms; k++) {
330 + uint8_t* atom_ptr = reinterpret_cast<uint8_t*>(*recv_buffer);
331 + uint16_t* q2w_ptr = reinterpret_cast<uint16_t*>(atom_ptr) + thread;
332 + uint8_t* q1w_ptr =
333 + reinterpret_cast<uint8_t*>(atom_ptr + kRankTileQ1Offset) + thread;
334 + int* qs_ptr = reinterpret_cast<int*>(atom_ptr + kRankTileScaleOffset) +
335 + (thread / 8);
336 +
337 + uint16_t q2w = __builtin_nontemporal_load(q2w_ptr);
338 + uint8_t q1w = *q1w_ptr;
339 + int qs = __builtin_nontemporal_load(qs_ptr);
340 +
341 + *recv_buffer += kRankBufferTileStride;
342 +
343 + // Unpack unsigned values [0, 7] then shift back to signed domain
344 + // [-4, +3] by adding kRangeMin.
345 + int32x4_t w;
346 + {
347 + int16_t qv[8];
348 +#pragma unroll
349 + for (int i = 0; i < 8; i++) {
350 + uint32_t low2 = (q2w >> (2 * i)) & 0x3u;
351 + uint32_t high1 = (q1w >> i) & 0x1u;
352 + qv[i] = static_cast<int16_t>(low2 | (high1 << 2));
353 + }
354 +
355 +#pragma unroll
356 + for (int i = 0; i < 4; i++) {
357 + int qpack = packed_from_int16_pair<T>(qv[2 * i], qv[2 * i + 1]);
358 + w[i] = packed_add<T>(qpack, kRangeMin);
359 + }
360 + }
361 +
362 + // Apply decode scale to reconstruct fp16/bf16 lanes.
363 + for (int i = 0; i < 4; i++) {
364 + w[i] = packed_mul<T>(w[i], qs);
365 + }
366 +
367 + data[k] = w;
368 + }
369 + }
370 +};
371 +
372 // Int6 symmetric quantization codec.
373 // We quantize the FP16 data to block-scaled Int6 in blocks of 4 *
374 // kThreadGroupSize.
@@ -377,7 +540,6 @@ struct CodecQ6 : public CodecBase {
540 w[i] = packed_mul<T>(w[i], qs);
541 }
542
380 - // That's pretty much it...
543 data[k] = w;
544 }
545 }
tests/distributed/test_quick_all_reduce.py
+2 -2
@@ -350,7 +350,7 @@ def bf16_cast_quickreduce(
350 @pytest.mark.skipif(
351 not current_platform.is_rocm(), reason="only test quick allreduce for rocm"
352 )
353 -@pytest.mark.parametrize("quant_mode", ["FP", "INT8", "INT6", "INT4"])
353 +@pytest.mark.parametrize("quant_mode", ["FP", "INT8", "INT6", "INT4", "INT3"])
354 @pytest.mark.parametrize("tp_size", [2])
355 @pytest.mark.parametrize("pipeline_parallel_size", [1, 2])
356 @pytest.mark.parametrize("test_target", [graph_quickreduce, eager_quickreduce])
@@ -438,7 +438,7 @@ def qr_variable_input(rank, world_size):
438 s2 = 2048
439 inp1 = torch.ones((s1, s2), dtype=dtype, device=device_idx)
440 result = torch.empty_like(inp1)
441 - # FP = 0 INT8 = 1 INT6 = 2 INT4 = 3 NONE = 4
441 + # FP = 0 INT8 = 1 INT6 = 2 INT4 = 3 INT3 = 4
442 ops.qr_all_reduce(_ptr, inp1, result, 3, cast_bf2half=True)
443 try:
444 if inp1[0, 0] == 0:
vllm/distributed/device_communicators/quick_all_reduce.py
+37 -10
@@ -28,11 +28,13 @@ from vllm.distributed.utils import is_weak_contiguous # noqa: E402, F401
28
29
30 class QuickReduceRegime(Enum):
31 + # Keep integer ids aligned with csrc/quickreduce/quick_reduce.h
32 FP = 0
33 INT8 = 1
34 INT6 = 2
35 INT4 = 3
35 - NONE = 4
36 + INT3 = 4
37 + NONE = 5
38
39
40 KB = 1024
@@ -43,14 +45,20 @@ class QuickAllReduce:
45 _SUPPORTED_WORLD_SIZES = [2, 4, 8]
46 _SUPPORTED_DTYPES = [torch.float16, torch.bfloat16]
47 # The following data is based on kernel tests.
46 - # In this order [FP, INT8, INT6, INT4].
48 + # In this order [FP, INT8, INT6, INT4, INT3].
49 _QR_MIN_SIZE = {
48 - (torch.float16, 2): [1 * MB, 2 * MB, 2 * MB, 1 * MB],
49 - (torch.float16, 4): [1 * MB, 16 * MB, 4 * MB, 2 * MB],
50 - (torch.float16, 8): [16 * MB, 4 * MB, 4 * MB, 2 * MB],
51 - (torch.bfloat16, 2): [2 * MB, 8 * MB, 8 * MB, 8 * MB],
52 - (torch.bfloat16, 4): [8 * MB, 64 * MB, 64 * MB, 16 * MB],
53 - (torch.bfloat16, 8): [16 * MB, 2048 * MB, 2048 * MB, 2048 * MB],
50 + (torch.float16, 2): [1 * MB, 2 * MB, 2 * MB, 1 * MB, 1 * MB],
51 + (torch.float16, 4): [1 * MB, 16 * MB, 4 * MB, 2 * MB, 2 * MB],
52 + (torch.float16, 8): [16 * MB, 4 * MB, 4 * MB, 2 * MB, 2 * MB],
53 + (torch.bfloat16, 2): [2 * MB, 8 * MB, 8 * MB, 8 * MB, 8 * MB],
54 + (torch.bfloat16, 4): [8 * MB, 64 * MB, 64 * MB, 16 * MB, 16 * MB],
55 + (torch.bfloat16, 8): [
56 + 16 * MB,
57 + 2048 * MB,
58 + 2048 * MB,
59 + 2048 * MB,
60 + 2048 * MB,
61 + ],
62 }
63
64 def __init__(self, group: ProcessGroup, device: int | str | torch.device) -> None:
@@ -59,8 +67,10 @@ class QuickAllReduce:
67 available for CUDA and ROCm MI300 series.
68
69 Custom quick allreduce leverages quantization for further
62 - acceleration on ROCm. It currently supports Q8, Q6, and Q4
63 - quantization formats and FP(float16, bfloat16).
70 + acceleration on ROCm. It currently supports Q8, Q6, Q4, and Q3
71 + quantization formats and FP(float16, bfloat16). Q3 (INT3) is
72 + restricted to TP2 (world_size == 2) due to poor performance on
73 + larger world sizes.
74
75 Quick allreduce is designed as a complement to custom allreduce.
76 Its initialization requires even stricter conditions.
@@ -178,6 +188,23 @@ class QuickAllReduce:
188 )
189 return
190 self.qr_quant_level = QuickReduceRegime[regime_str]
191 +
192 + # INT3 is only enabled for TP2 (world_size == 2).
193 + # Kernel benchmarks show INT3 all-reduce on TP4/TP8 has poor
194 + # performance (the extra ranks make the 3-bit codec's pack/unpack
195 + # overhead outweigh the reduced communication volume), so INT3 is
196 + # restricted to 2-GPU tensor parallelism. For TP4/TP8 use a wider
197 + # codec (e.g. INT4) or NONE instead.
198 + if self.qr_quant_level == QuickReduceRegime.INT3 and self.world_size != 2:
199 + logger.warning(
200 + "Custom quick allreduce is disabled: INT3 quantization is "
201 + "only supported for TP2 (world_size == 2), but world_size "
202 + "is %d. INT3 on TP4/TP8 is disabled due to poor kernel "
203 + "performance. Use INT4/NONE for this world size.",
204 + self.world_size,
205 + )
206 + return
207 +
208 self.qr_quantization_min_size = self._get_qr_quantization_min_size()
209 vllm_config = get_current_vllm_config_or_none()
210 if (
vllm/envs.py
+3 -3
@@ -214,7 +214,7 @@ if TYPE_CHECKING:
214 VLLM_SSM_CONV_STATE_LAYOUT: Literal["SD", "DS"] | None = None
215 VLLM_COMPUTE_NANS_IN_LOGITS: bool = False
216 VLLM_ROCM_QUICK_REDUCE_QUANTIZATION: Literal[
217 - "FP", "INT8", "INT6", "INT4", "NONE"
217 + "FP", "INT8", "INT6", "INT4", "INT3", "NONE"
218 ] = "NONE"
219 VLLM_ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16: bool = True
220 VLLM_ROCM_QUICK_REDUCE_MAX_SIZE_BYTES_MB: int | None = None
@@ -1224,12 +1224,12 @@ environment_variables: dict[str, Callable[[], Any]] = {
1224 os.getenv("VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT", "False").lower() in ("true", "1")
1225 ),
1226 # Custom quick allreduce kernel for MI3* cards
1227 - # Choice of quantization level: FP, INT8, INT6, INT4 or NONE
1227 + # Choice of quantization level: FP, INT8, INT6, INT4, INT3 or NONE
1228 # Recommended for large models to get allreduce
1229 "VLLM_ROCM_QUICK_REDUCE_QUANTIZATION": env_with_choices(
1230 "VLLM_ROCM_QUICK_REDUCE_QUANTIZATION",
1231 "NONE",
1232 - ["FP", "INT8", "INT6", "INT4", "NONE"],
1232 + ["FP", "INT8", "INT6", "INT4", "INT3", "NONE"],
1233 ),
1234 # Custom quick allreduce kernel for MI3* cards
1235 # Due to the lack of the bfloat16 asm instruction, bfloat16