An Attempt at a Fused-Operator Strategy for DeepSeek V3.2

Oct 13, 2026

Chinese version

Code: EdgePro001/FusedIndexTopK (measurements use v0.3.0, commit 05b44daa). This post covers only the prefill-side operator work. Every number comes from raw experimental results and was checked by a script.

TL;DR

1 Introduction

1.1 The problem

DSA scores tokens as

It,s=∑j=164wt,j⋅ReLU ⁣(qt,j⋅ks)I_{t,s} = \sum_{j=1}^{64} w_{t,j} \cdot \mathrm{ReLU}\!\left(\mathbf{q}_{t,j} \cdot \mathbf{k}_{s}\right)

where tt is the query position, ss a history token, and jj an Indexer head.

Each query tt scores every valid history token ss and passes the K=2048 highest-scoring tokens to sparse MLA. The main attention therefore grows only with K, but Indexer scoring still grows with history length, and the total scoring work of a causal prefill is quadratic.

Table 1 attributes cost in native vLLM on a real corpus: six full input lengths from the same normal-A input pack, with Nsight Systems attributing kernels to the Indexer’s scoring and top-k through NVTX ranges.

Full input lengthIndexer cumulative GPU time (ms)Non-communication kernel time (ms)Share
8K291.15375.65.42%
16K1105.211492.29.62%
32K4236.825244.816.78%
64K16703.258884.828.37%
128K66721.3151246.244.11%
~160K104917.9210734.249.79%

Table 1: Indexer work share in native vLLM. 6×H20, TP2 / PP3, eager, concurrency 1, chunked prefill 8192. The denominator is the sum of kernel time over six GPUs, not TTFT, so Amdahl’s law cannot be applied to it directly.

The usual implementation has two steps: a DeepGEMM scoring kernel writes the [Q,N][Q, N] FP32 score matrix, and a separate top-k kernel reads it back. For Q=4096 and N=131072, the score matrix is 2 GiB and one write plus one read move 4 GiB, while the final int32 index output is only 32 MiB.

1.2 Goals and constraints

1.3 Contributions

  1. An exact top-k that pipelines well: estimate a filtering threshold by sampling, do only “compare + append” over the full row, then find the KK-th largest score in the small candidate set with radix select. A “count is sufficient + set is complete” certificate decides whether the fast path is valid; failures go to exact repair (Section 3).
  2. A warp-specialized fused operator: TMA, Math and TopK warps each have one job, and a ready / free double buffer overlaps them across queries. Four further inefficiencies are then removed in turn: candidate-write contention, scanning empty slots, whole-row recomputation after overflow, and scheduling tails (Section 4).
  3. Attribution by intervention: a Math-only control, SM-assignment traces and CTA-count interventions tie each gain to a specific mechanism and rule out several plausible but wrong explanations (Sections 5 and 7).
  4. Validation from operator to model: freeze a public release, time the complete operator on 240 real activations, measure TTFT inside vLLM, and check that the two agree in magnitude (Section 6).

The matrix-computation core (TMA loads, WGMMA instructions, SMEM descriptors and swizzle) comes from DeepGEMM; radix select and warp collectives are established techniques. This project’s work is the candidate layout, on-chip producer–consumer synchronization, capacity recovery and shape scheduling needed to combine the two, plus evidence-driven performance validation.

2 Background: hardware and arithmetic intensity

2.1 Hardware

All experiments run on NVIDIA H20-3e:

H20 has roughly one seventh of H100’s compute but higher bandwidth (3.35 TB/s on H100 SXM), so many computations that are memory-bound on H100 become compute-bound on H20.

2.2 Indexer scoring is compute-bound

Scoring one key against one query row means a 128-dimensional dot product for each of 64 heads, i.e. 2×64×128=163842 \times 64 \times 128 = 16384 FLOP. One key costs a 128 B FP8 vector plus a 4 B scale, 132 B in total. Hence

intensity≈16384 FLOP132 B≈124 FLOP/B  >  ridge 62 FLOP/B\text{intensity} \approx \frac{16384\ \text{FLOP}}{132\ \text{B}} \approx 124\ \text{FLOP/B} \;>\; \text{ridge}\ 62\ \text{FLOP/B}

This is a conservative lower bound that assumes each key is used by a single query row. In prefill a key is reused by many query rows, so the real HBM intensity is higher. All 64 heads share one key (MQA), which is the root cause of the high intensity.

This sets the optimization target. At 128K, writing and reading back the 4 GiB score matrix takes about 0.9 ms at 4.8 TB/s, while the unfused pipeline takes 33.738 ms in total (Table 11), so this traffic is about 2.7% of it. The main gain of fusion therefore cannot come from saving bandwidth; it has to come from no longer running top-k serially after scoring. The version trajectory in Section 5 confirms this.

2.3 Problem definition

Exactness is checked as follows:

3 Exact top-k: make selection keep up first

Fusion only works if top-k keeps up with scoring; otherwise candidates pile up and stall the Math warps. The first stage therefore optimizes a standalone top-k over full score matrices (Q=4096, K=2048).

3.1 Counterexample: online maintenance

The most direct idea is to maintain candidates while scanning, based on TopK(A∪B)=TopK(TopK(A)∪B)\mathrm{TopK}(A \cup B) = \mathrm{TopK}\big(\mathrm{TopK}(A) \cup B\big).

Version 1: each thread keeps a top-16 in registers. Every accepted value forces a rescan of the local array to find the new minimum. NCU at N=128K:

Version 2: a min-heap in SMEM. Maintenance drops to O(log⁡L)O(\log L), and input reads become warp-contiguous:

Lesson: as long as scanning is coupled to online maintenance, lowering the complexity does not remove the maintenance itself.

3.2 Sampled threshold: turn maintenance into filtering

Before scanning, each query estimates a fixed filtering threshold τ\tau:

  1. Estimate the threshold: sample about 0.5% of tokens spread across the row and estimate τ\tau from their distribution, targeting about 4096 candidates, twice KK, to absorb sampling error.
  2. Filter the full row: scan the whole row with τ\tau fixed and append every element with score≥τ\text{score} \ge \tau to the candidate set CC. The scan only compares, counts and writes; there is no ordered state to maintain.
  3. Select exactly: run four 8-bit radix passes over CC to find the exact KK-th largest score θ\theta, then emit by θ\theta.

Correctness certificate. Suppose C={(score,index):score≥τ}C = \{(\text{score}, \text{index}) : \text{score} \ge \tau\} is stored completely and K≤∣C∣≤capacityK \le |C| \le \text{capacity}. Then the row’s KK-th largest score is ≥τ\ge \tau, no element below the threshold can enter the top-k, and therefore

TopK(row)=TopK(C)\mathrm{TopK}(\text{row}) = \mathrm{TopK}(C)

The fast path fails in exactly two cases:

Both go to exact repair. Sampling affects only whether the fast path succeeds, never whether the answer is exact.

The radix part maps floating-point scores to order-preserving 32-bit integers and counts 256 buckets per pass from the high byte to the low byte. The remaining rank at the end is exactly “how many elements equal to θ\theta still need to be taken”, so no sort is required.

Effect: at 128K, time drops from 4.2200 ms for the heap version to 2.7478 ms. Paired NCU shows executed instructions 1.565B → 1.007B and shared loads 196.89M → 7.87M with unchanged occupancy: the gain comes from no longer maintaining a heap.

3.3 Resource thresholds: residency follows thresholds, not usage

Each candidate stores a score and an index, 8 B in total. Cutting candidate capacity from 16384 to 14080 and constraining register allocation:

Counterexample: shrinking SMEM further by storing only indices (68224 B). A 1024-thread CTA already caps residency at 2, so no parallelism is gained, while radix select must repeatedly fetch scores from device memory. All eight real test points got slower by 2.60%–13.54%.

3.4 Instruction-level optimizations

3.5 Stage result

NFlashInfer (ms)Our top-k (ms)Speedup
16K0.6600.6472%
64K1.5061.14931%
128K2.4941.65151%
160K3.2191.85274%

Table 2: Standalone top-k against FlashInfer FilteredTopK. Real Layer-0 scores, mean of two inputs per N; speedup = FlashInfer ÷ ours − 1. FlashInfer is a single same-protocol comparison in the same block, covering Layer 0 only; deeper layers have different score distributions, so this is not a claim of across-the-board superiority.

Top-k now keeps up, but a standalone top-k cannot change the structure “write the score matrix, read it back, run the two stages serially”.

4 Design of the fused operator

4.1 Roles and compute mapping

RoleThreadsResponsibility
TMA1 warpasynchronous Q and KV tile loads
Math8 warps (2 warpgroups)WGMMA scoring, head reduction, filtering candidates by τ\tau
TopK3 warpsexact radix selection, index output

Table 3: Roles inside one CTA: 384 threads, one CTA per SM. TMA and TopK share one 128-thread warpgroup.

A KV tile has 128 rows, 64 per warpgroup. The WGMMA shape is m64nNk32:

Each warp owns 16 KV rows, and the head reduction happens in registers: each lane first sums its own 16 columns, then two shfl_xor steps sum across 4 lanes. Each warp therefore ends up with exactly 16 final scores per query and filters them with a single ballot. Scores are reduced and filtered in place in registers, never written out; this is what makes filtering in the epilogue possible.

Between TMA and WGMMA we keep DeepGEMM’s stage ring: a full / empty barrier pair per stage plus a phase bit to tell rounds apart. We first measured this level on stock DeepGEMM: going from 1 to 2 KV stages lowers latency by 13.1% (16K) / 24.3% (128K), a third stage brings nothing, and the Tensor instruction family is about 95% active. This level is already near peak; the remaining room is in the new level added behind it, Math ↔ TopK.

4.2 Double buffering: overlapping Math and TopK across queries

The first version with candidates and top-k in the same CTA took 6.616 ms on a real N16K input. Re-measured on the same input, its effective throughput is 147 TFLOPS, lower than the unfused pipeline’s 176. The reason: after a batch of queries, Math had to wait for TopK to finish before writing the next batch. Removing the round trip through device memory also removed the parallelism.

The fix gives candidates two slots. Query batch bb uses slot b mod 2b \bmod 2 with phase bit ⌊b/2⌋ mod 2\lfloor b/2 \rfloor \bmod 2:

MetricSerialOverlapped across queries
Complete operator time5.498 ms3.848 ms (−30.0%)
Eligible warps / scheduler0.2690.446
Issue slots busy24.32%35.34%
Achieved occupancy18.71%18.20%

Table 4: Ablation with the same layout, changing only whether Math waits for TopK. H20, Q4096 / N16K, synthetic input, sampling and repair included.

Occupancy is unchanged while more warps can issue: the gain comes from less waiting, not from higher residency.

4.3 Candidate layout: warp-private segments

Each row’s candidate area is split into 8 segments, one per Math warp, with 736 entries each and 5888 per row. Each warp keeps its segment count in registers with ballot and popcount and computes write positions directly, without atomically allocating from a counter shared by the whole CTA.

The cost is that segments cannot borrow capacity from each other: once a segment is full, the row counts as overflowed even if other segments still have room. Merging each warp’s two former 368-entry segments into one 736-entry segment lowered complete-operator time by 0.04%–4.37% on four real 160K inputs.

4.4 Scanning only the actual counts

TopK used to walk the whole reserved capacity and test each position for validity. After switching to iterate over each segment’s actual count (Q4096 / N16K):

Interpretation: Math was hardly waiting for TopK in the first place; Math sets the pace. TopK is already hidden by the pipeline, and the remaining losses are in the exception path and task scheduling.

4.5 Overflow spill area: avoiding whole-row recomputation

On the real 160K development set only 0.664% (544 / 81920) of rows overflowed, yet only 6 of 20 inputs beat DeepGEMM + DeepSelect, because an overflowing row discarded its candidates and rescored the whole row. We first classified the failures:

Failure cause (544 rows in total)Rows
One segment overflowed, row total within capacity449
Row total exceeded capacity95
Fewer than K candidates0

Table 5: Failure classification on the 160K development set.

The 449 rows are exactly the cost of “segments cannot borrow” from Section 4.3. The fix keeps the SMEM normal path and writes the few overflowing candidates to a spill area in device memory (256 entries per segment); TopK merges both sources and completes the exact selection directly. If the spill area also fills, or there are fewer than K candidates, the original exact repair still applies.

SchemeRows recomputedComplete time
Discard and recompute54442.072 ms
Keep and merge040.387 ms
DeepGEMM + DeepSelect—41.830 ms

Table 6: Q4096 / N160K, 5 layers × 4 real inputs. The 6 cases without overflow changed by only about 0.011%.

The development set went from 6/20 to 20/20 wins. On an independent holdout not used during development (five layers, six native N, 120 inputs), 16K–160K won 100/100 while 8K won 0/20 (4.26% slower on average). The 8K gap was closed by the later BLOCK_Q=1 and output-path changes; the public release is also faster than the baseline at 8K (Table 11).

4.6 One query per batch: balancing work across SMs

A kernel finishes when its busiest SM finishes. Queries are handed to SMs in tasks of BLOCK_Q rows: with Q=512 and 78 SMs, that is 6.56 rows on average. With BLOCK_Q=2 every SM gets an even number of rows, and %smid / %globaltimer traces show 22 SMs taking 8 rows. With BLOCK_Q=1 the busiest SM takes only 7 rows (44 SMs).

Ruling out the wrong explanation. Tensor-family activity rises from 79.6% to 91.4%, which looks like “narrower MMAs are more efficient”. But the absolute Tensor active cycles are identical in both versions; the activity rises only because the denominator shrank, and per-query Math cycles are unchanged (1.058M → 1.060M).

Intervention. Keeping inputs and kernels fixed, only the number of persistent CTAs is changed:

CTAs2 queries per batch1 query per batch
1568 rows · 4.807 ms7 rows · 4.207 ms
1288 rows · 4.812 ms8 rows · 4.787 ms
1048 rows · 4.822 ms10 rows · 5.962 ms
9610 rows · 5.975 ms10 rows · 5.967 ms

Table 7: Rows on the busiest SM and main-kernel time.

Time depends only on the rows of the busiest SM, not on rows per batch:

Tkernel≈0.6 ms×max⁡SM(rows),4.2074.807=0.875=78T_{\text{kernel}} \approx 0.6\ \text{ms} \times \max_{\text{SM}} (\text{rows}), \qquad \frac{4.207}{4.807} = 0.875 = \frac{7}{8}

Over 420 real inputs, the geometric mean of Q2 ÷ Q1 is about 1.77× for Q=8 and Q=64, about 1.15× for Q=512 and 1.02×–1.04× for Q≥1024, with identical final index sets throughout.

5 How far from the ceiling

5.1 Version trajectory

To see how far the optimizations went together, we re-measured the unfused pipelines and every historical version on the same real inputs with the same timing protocol.

Effective throughput counts only the scoring that the causal mask actually requires, and time is the complete operator (sampling, main kernel and repair):

effective throughput=2⋅64⋅128⋅∑t(kend(t)−kstart(t))complete operator time\text{effective throughput} = \frac{2 \cdot 64 \cdot 128 \cdot \sum_{t} \left(k_{\text{end}}(t) - k_{\text{start}}(t)\right)}{\text{complete operator time}}

Figure 1

Figure 1: Roofline and version trajectory on H20-3e. Left: every version has the same arithmetic intensity (about 124 FLOP/B), in the compute-bound region right of the ridge at 62. Right: effective throughput on the same inputs with the same protocol (eager); hollow points are the unfused DeepGEMM + torch.topk, dotted lines DeepGEMM + DeepSelect.

ShapeUnfused (torch.topk)Fused versionsPublic v0.3.0DeepGEMM + DeepSelect
Q4096 / N16K (1 input)176 (59%)R17a 147 → R17b 251 → R17c 254 → R17f 255262 (88%)226
Q4096 / N160K (20 inputs)199 (67%)R17j 263 → R17k 267271 (92%)259
Q512 / N128K (1 input)171 (58%)R17k 220251 (85%)217

Table 8a: Effective FP8 throughput (TFLOPS, eager); percentages are of the 296 TFLOPS spec peak. CUDA Graph differs from eager by only 1–2 TFLOPS.

5.2 Math-only control: how much selection time is still exposed

On a copy of the public main kernel we kept the TMA loads, WGMMA and barriers and removed score reduction, candidate generation and top-k. The WGMMA accumulators are folded into a checksum that is written out, so the compiler cannot delete the matrix computation. NCU on Q4096 / H128K confirms that both versions execute identical dynamic QGMMA (68,190,208) and TMA load (4,265,984) instruction counts, while total SASS instructions drop by 85.4%.

QHistoryMain-kernel speedup after removing all selection
8192128K0.20%
4096128K0.31%
409624K1.64%
512128K2.00%
5128K21.75%

Table 9: Math-only control. Synthetic input, timed alternately with the unmodified version in the same rounds.

85% fewer instructions, only 0.31% less time: with long histories, the selection work is almost entirely off the critical path. Note that this number is “the selection time still exposed on the current matrix path”, not the cost of selection itself, and not an upper bound for every possible optimization. With short histories the matrix work cannot cover selection, and removing selection still saves 21.75%.

5.3 Main-kernel compute utilization

QNEffective TFLOPSOf peakSMs usedTopK busy fraction
40968K26489.1%780.85
409616K27392.2%780.43
409632K27793.5%780.19
409664K27994.1%780.09
4096128K27994.4%780.05
4096160K28295.2%780.04
512128K26188.1%780.05
64128K22074.4%640.04
8128K289.4%80.04

Table 10: Public main kernel (sampling and repair excluded), real replay rows, fixed threshold, 20 timing groups. The six Q4096 rows come from one measurement (2026-10-13); the last three from an earlier one (2026-10-05). Shapes measured in both runs, Q4096 / N8K at 89.1% vs 88.7% and N32K at 93.5% in both, indicate run-to-run repeatability. TopK busy fraction = time TopK warps spend processing candidates ÷ (processing time + time waiting for candidates to become ready), from a separate instrumented launch; use it for trends only.

At Q4096 all 78 SMs have work, and the main kernel rises from 89.1% at 8K to 95.2% at 160K. The last column explains why: at 8K the TopK warps spend 85% of their time processing candidates and barely keep up with scoring; from 64K on it is under 10%, and they mostly wait for Math to produce the next batch. This agrees with Section 5.2: the longer the history, the more matrix work each row has to cover selection.

When Q is smaller than the SM count, per-query parallelism runs out: Q64 uses only 64 SMs and Q8 only 8, and an idle TopK cannot help because parallelism is the bottleneck. In the 2026-10-05 measurement, Q128 / Q256 (N128K) used all SMs yet reached only 75.1% / 76.8%: 128 and 256 rows do not divide evenly over 78 SMs, so the busiest SM processes one extra row, the scheduling tail of Section 4.6.

Summary: in the main regime of long context and large Q the pipeline is full and this approach is close to its ceiling; further large gains require doing less matrix work. The remaining limits are “short histories cannot hide selection” and “small Q lacks parallelism”.

6 Evaluation

6.1 Setup

6.2 Operator performance

NOurs (ms)DeepGEMM + DeepSelect (ms)Speedup
8K1.7951.8721.04×
16K3.6854.2581.16×
32K7.7228.6941.13×
64K15.76117.1311.09×
128K31.91233.7381.06×
160K39.83641.8701.05×

Table 11: Q4096 complete-operator time (CUDA Graph). Each N is the arithmetic mean of per-input medians over 20 inputs; speedup = baseline ÷ ours.

QInputsvs DeepGEMM + DeepSelect (geometric mean)
64601.561×
512601.125×
40961201.086×

Table 12: Geometric mean of per-case speedups by Q. 1.086× should not be read as “8.6% less time”.

vLLM’s usual chunked-prefill budget is 8192 tokens, so Q8192 is a shape that really occurs. The real activations were captured with only 4096 queries each, so Q8192 exists only as synthetic input:

NOurs (ms)DeepGEMM + DeepSelect (ms)Speedup
16K6.2167.3561.18×
32K14.16316.2741.15×
64K30.05732.8001.09×
128K62.25665.1331.05×
160K77.70481.4741.05×

Table 12b: Synthetic Q8192 (CUDA Graph), mean of the medians of two random seeds. Q8192 / N8K is not measured because some rows have fewer than K valid keys. Synthetic input does not replace real activations; it only confirms that the trend matches Q4096.

6.3 Correctness

These checks are not an independent oracle on every row, and they do not prove token-identical model generations.

6.4 End to end: TTFT

Setup:

Input lengthNative vLLMDeepGEMM + DeepSelectOursvs native
8K2.7512.7622.761fused path not invoked
16K4.0294.0864.022−0.19%
32K6.6536.7736.628−0.36%
64K12.96313.22012.883−0.62%
128K29.88630.43329.396−1.64%

Table 13: TTFT (seconds), mean of the two inputs’ medians. Per-case paired bootstrap 95% intervals are narrow (e.g. 1.0162× to 1.0169× at 128K), but they reflect only within-session repeatability; two inputs do not represent the corpus.

6.5 Magnitude check

The end-to-end gain should be roughly the Indexer’s share times the operator’s saving relative to native:

ΔTTFT≈Indexer share×saving vs native\Delta\text{TTFT} \approx \text{Indexer share} \times \text{saving vs native}
Input lengthNative Indexer shareSaving vs nativeEstimateMeasured TTFT reduction
16K9.62%5.99%≈ 0.6%0.19%
32K16.78%4.33%≈ 0.7%0.36%
64K28.37%3.42%≈ 1.0%0.62%
128K44.11%4.43%≈ 2.0%1.64%

Table 14: The saving is the reduction in Indexer GPU work of the fused version relative to native over the many Q/N shapes actually invoked, including sampling, repair and adapter checks.

The two agree in magnitude and both grow with length. The measurement falls below the estimate for several reasons:

The end-to-end gain is therefore capped by the Indexer’s share.

7 Lessons: where expectation and measurement disagreed

AttemptExpectedMeasuredLesson
Heap maintenanceO(log⁡L)O(\log L), catch up with FlashInferHalf the instructions, but 128K still 4.220 ms, slower than FlashInferLower complexity does not remove maintenance
Index-only storageLess SMEM, so fasterAll eight test points 2.60%–13.54% slowerResidency follows thresholds, not usage
First on-chip fusionNo round trip, so faster6.616 ms, slower than unfused (147 vs 176 TFLOPS)Fusion must design who waits for whom
Scanning actual countsTopK 26% faster, so overall clearly fasterComplete operator only 1.09% fasterTopK was already hidden by the pipeline
One query per batchNarrower MMAs are more efficientPer-query cycles unchanged; the busiest SM does one row lessMetrics need intervention experiments
Hand-cut instructionsFewer instructions, so fasterStatic SHFL 20 → 15, FADD 30 → 25, no reliable end-to-end changeInstruction count is not time

Table 15: Six judgments overturned by data. Each row maps to a section above; their measurement scopes differ, so numbers are not compared across rows.

One lesson runs through all of them: find the path that determines total time before acting on it. When a metric improves, first ask whether it is on that path, then whether the numerator or the denominator changed.

8 Limitations

10 Conclusion

Removing the intermediate matrix is only the starting point. On H20, Indexer scoring is compute-bound, so the value of fusion lies mainly in overlapping exact selection with scoring rather than in saving bandwidth. To get there, we first changed top-k from online maintenance to sampled filtering plus exact selection over a small set, then put it into the scoring pipeline with warp specialization and double buffering, and removed candidate-write contention, empty-slot scans, overflow recomputation and scheduling tails in turn. The complete operator reaches about 92% of FP8 peak, selection is almost fully hidden at long histories, the operator is consistently faster than the unfused pipeline on real activations, and inside vLLM it turns into a TTFT gain consistent with the estimate.

The remaining room on the prefill side has to come from doing less matrix work; small Q and decode need parallelism split along the KV dimension.

Appendix A: Reproduction

ItemValue
Public releasev0.3.0, 05b44daa14991d241de26162aa1bcb773cb6dafb
DeepGEMM7c95b14aa4a66edd7b682e5acdde62351ca81197
DeepSelect0f03b68748b304863fdf0181a11458d04ae533a9
Operator environmentNVIDIA H20-3e, PyTorch 2.10.0+cu130
End-to-end environmentvLLM 0.28.0, PyTorch 2.13.0+cu132, 6×H20-3e, TP2 / PP3

Appendix B: Timing scopes

SectionScope
3 (standalone top-k)Sum of CUPTI kernel time over the top-k stage, including sampling, filtering, selection, output and repair; real Layer-0 score replay
4 (fused design)Warm median of complete-operator CUDA events, 24 alternating groups; cycles from a separate instrumented build
5.1 (version trajectory)Complete operator, eager, CUDA events; same inputs, rotated
5.2, 5.3 (main kernel)Main kernel only, sampling and repair excluded
6.2 (operator)Complete operator, CUDA Graph, 7 paired groups
6.4 (end to end)Whole-model TTFT, eager, concurrency 1, 8 Williams-ordered rounds

Numbers are compared only within the same scope.