Assisted By Claude Code:Opus-4.6 Signed-off-by: Maxime Elkael <m.elkael@northeastern.edu>
22 KiB
gNB MAC Scheduler Architecture
Overview
The gNB MAC scheduler runs once per DL slot. It allocates PDSCH resources for the current slot and PUSCH resources for a future UL slot (selected via K2), sending the UL DCI grant in the current DL slot. The DL and UL schedulers follow the same pipeline design: a fixed orchestration loop calls a sequence of pluggable function pointers, each handling one stage of the scheduling decision. Any stage can be replaced independently without touching the rest of the pipeline.
Both schedulers send their DCIs in the same DL slot and share the same physical
CORESET/CCE resources. The UL scheduler runs first (nr_schedule_ulsch in
gNB_scheduler.c), then the DL scheduler (nr_schedule_ue_spec). CCEs are claimed on
a first-come basis via get_cce_index + fill_pdcch_vrb_map, so UL DCIs take
priority. If CCE space is tight, DL UEs are more likely to be skipped. The two
schedulers use separate FAPI containers (UL_dci_req for UL grants, DL_req for DL
grants) but the underlying CCE map is shared.
All scheduler code lives under openair2/LAYER2/NR_MAC_gNB/. The DL scheduler is in
gNB_scheduler_dlsch.c,
the UL scheduler in
gNB_scheduler_ulsch.c,
and shared primitives in
gNB_scheduler_primitives.c.
Data structures and typedefs are in
nr_mac_gNB.h.
Default policy implementations are in
gNB_scheduler_dlsch_default_policies.c
and
gNB_scheduler_ulsch_default_policies.c.
Function pointers are assigned at startup in
main.c.
Scheduler Pipeline
Both DL and UL follow the same 7-stage pipeline, plus a DL-only 8th stage.
DL UL
┌────────────┐ ┌────────────┐
│ 1.Collect │ │ 1.Collect │ ◄── nr_ulsch_preprocessor
│ Candidates │ │ Candidates │ (iterates over
└─────┬──────┘ └─────┬──────┘ reachable UL slots)
│ │
┌─────┴──────┐ ┌─────┴──────┐
│ 2. RI/PMI │ │ 2. RI/TPMI│ ◄─┐
│ Selection │ │ Selection │ │
└─────┬──────┘ └─────┬──────┘ │
│ │ │
┌─────┴──────┐ ┌─────┴──────┐ │
│ 3. Beam │ │ 3. Beam │ │
│ Selection │ │ Selection │ │
└─────┬──────┘ └─────┬──────┘ │
│ │ │ nr_ul_schedule
┌─────┴──────┐ ┌─────┴──────┐ │ (per UL slot)
│ 4. TDA │ │ 4. TDA │ │
│ Selection │ │ Selection │ │
└─────┬──────┘ └─────┬──────┘ │
│ │ │
┌─────┴──────┐ ┌─────┴──────┐ │
│ 5. MCS │ │ 5. MCS │ │
│ Selection │ │ Selection │ │
└─────┬──────┘ └─────┬──────┘ │
│ │ │
┌─────┴──────┐ ┌─────┴──────┐ │
│ 6. RB │ │ 6. RB │ │
│ Allocation │ │ Allocation │ │
│ (per beam) │ │ (per beam) │ │
└─────┬──────┘ └─────┬──────┘ │
│ │ │
┌─────┴──────┐ ┌─────┴──────┐ │
│ 7.Dispatch│ │ 7.Dispatch│ ◄─┘
│ (fixed) │ │ (fixed) │
└─────┬──────┘ └────────────┘
│
┌─────┴──────┐
│ 8. LCID │ (DL only)
│ Allocation │
└────────────┘
UL preprocessor split
The UL pipeline is split across two functions. The preprocessor
(nr_ulsch_preprocessor) runs a loop over multiple candidate UL slots: for each DL
slot there may be several reachable UL slots (depending on K2), and the scheduler must
pick one. The loop iterates until the DCI budget is exhausted or no TDA can reach
further UL slots.
Only step 1 (candidate collection) lives in the preprocessor loop. Candidates depend
on sched_frame/sched_slot (e.g. inactivity checks), so they are rebuilt each
iteration. The preprocessor also runs a lightweight TDA reachability gate
(get_num_ul_tda == 0 → break) to short-circuit the loop when no TDA can reach the
target slot.
Steps 2–7 live in nr_ul_schedule, called once per UL slot with the collected
candidates. k2 is passed as a parameter for TDA selection. This mirrors the DL
structure where all stages run in a single function.
Beam selection (step 3) runs before TDA selection (step 4) so that TDA selection can use the allocated beam to check the correct VRB map and pick the best TDA per beam.
The preprocessor itself is a pluggable function pointer (pre_processor_ul), so a
custom implementation can replace the entire outer strategy — including how many UL
slots to target and what to do when the DCI budget is limited.
Candidate struct
Each candidate is an nr_{dl,ul}_candidate_t struct that flows through the pipeline,
accumulating decisions. Its fields are split into three sections:
- Identity / scheduling state — set by collect, read-only afterwards:
UEpointer,rnti, HARQ state, pending bytes, BLER estimate, current MCS, BWP geometry, QoS. - CSI observations — set by collect:
cqi(DL),beam_rsrp[],beam_sinr[](per-SSB). - gNB decisions — written by the stage indicated below. Some live in the
embedded
sched_pdsch/sched_puschsub-struct, others arealloc_-prefixed fields on the candidate itself:sched_p{d,u}sch.nrOfLayers,.pm_index(DL) /.tpmi(UL) — set by ri_pmi/tpmi_selectsched_p{d,u}sch.time_domain_allocation,.tda_info,alloc_slbitmap— set by tda_selectalloc_beam_idx,alloc_dci_beam_idx(UL), etc. — set by beam_selectsched_p{d,u}sch.mcs— set by mcs_selectsched_p{d,u}sch.rbStart,.rbSize— set by rb_allocalloc_cce_index,alloc_aggregation_level,alloc_sched_pdcch— set by commit_alloc
The policy (rb_alloc) writes to sched_p{d,u}sch.rbStart/rbSize/mcs via the
COMMIT_ALLOC / COMMIT_UL_ALLOC macro, which also runs CCE validation and sets
cand->scheduled = true. Only candidates with that flag set are dispatched.
Stage 1. Candidate Collection (fixed)
Functions: collect_dl_candidates(), collect_ul_candidates()
Iterates over connected UEs and builds an array of nr_{dl,ul}_candidate_t structs.
Populates the identity/state and CSI sections. Not a function pointer — fixed scaffolding
that feeds the pipeline.
Stage 2. RI / PMI Selection
Function pointers: mac->dl_ri_pmi_select (nr_dl_ri_pmi_select_fn),
mac->ul_ri_tpmi_select (nr_ul_ri_tpmi_select_fn)
Sets rank and precoder index per candidate. In DL, rank and PMI come from the UE's CSI report (RI + PMI). In UL, the gNB has full authority and reads rank and TPMI from SRS feedback.
DL outputs: cand->sched_pdsch.nrOfLayers, cand->sched_pdsch.pm_index.
UL outputs: cand->sched_pusch.nrOfLayers, cand->sched_pusch.tpmi.
Stage 3. Beam Selection
Function pointers: mac->dl_beam_select (nr_dl_beam_select_fn),
mac->ul_beam_select (nr_ul_beam_select_fn)
Assigns a beam structure index to each candidate. For UL, two beams are needed — one
for the DCI slot, one for the PUSCH slot — since they may fall in different beam periods.
Candidates that fail beam allocation (no beam available) are marked with skipped=true.
Placed before TDA selection so that TDA selection can use the allocated beam to check the correct VRB map and pick the best TDA per beam.
Outputs: cand->alloc_beam_idx (PUSCH/PDSCH beam), cand->alloc_dci_beam_idx (UL DCI beam).
Stage 4. TDA Selection
Function pointers: mac->dl_tda_select (nr_dl_tda_select_fn),
mac->ul_tda_select (nr_ul_tda_select_fn)
Picks the Time Domain Allocation for each candidate. For UL retransmissions, also
validates feasibility: if the TDA differs from the original transmission (e.g. because
targeting a different UL slot), the TBS must be preservable with the new symbol/DMRS
layout. Infeasible retx candidates are marked with skipped=true.
Outputs: cand->sched_p{d,u}sch.time_domain_allocation, .tda_info, cand->alloc_slbitmap. UL retx
candidates get cand->retx_rbSize set.
Returns: number of surviving candidates.
Stage 5. MCS Selection
Function pointers: mac->dl_mcs_select (nr_dl_mcs_select_fn),
mac->ul_mcs_select (nr_ul_mcs_select_fn)
Runs for all surviving candidates — including those that may ultimately not be
scheduled due to CCE failure. Sets sched_p{d,u}sch.mcs from BLER stats or SINR, and persists
the decision to {ul,dl}_bler_stats.mcs so continuity is maintained across slots even
for unscheduled UEs.
Placed after beam selection so custom implementations can use alloc_beam_idx and
beam_rsrp/sinr for beam-aware MCS adaptation.
Stage 6. RB Allocation (per beam)
Function pointers: mac->dl_rb_alloc (nr_dl_rb_alloc_fn),
mac->ul_rb_alloc (nr_ul_rb_alloc_fn)
The core resource allocation decision and the most likely stage to be replaced. Called
once per beam with candidates filtered to that beam. Decides which UEs to schedule and
how many PRBs each gets. MCS is already set on sched_p{d,u}sch.mcs by the previous
stage; the policy may refine it (e.g. downward PHR adjustment).
Each allocation must go through the COMMIT_ALLOC / COMMIT_UL_ALLOC macro, which
writes sched_p{d,u}sch.rbStart/rbSize/mcs onto the candidate, runs CCE validation
(and PUCCH validation on DL), and sets cand->scheduled = true. UEs that fail CCE are
skipped transparently.
Outputs: cand->scheduled flag on accepted candidates.
Returns: number of scheduled UEs.
Stage 7. Dispatch (fixed)
Not a function pointer. Iterates over candidates with scheduled == true, reads the alloc_* fields
from each candidate, applies CCE results to sched_ctrl, marks the VRB map, and calls
apply_{dl,ul}_{new_transmission,retransmission} to build the final
NR_sched_{pusch,pdsch}_t and trigger FAPI message preparation.
Stage 8. Per-LCID Byte Allocation (DL only)
Function pointer: mac->dl_lcid_alloc (nr_dl_lcid_alloc_fn)
Called inside generate_dl_mac_pdu for initial DL transmissions. Decides how many bytes
each logical channel gets within the available TBS. The candidate flows from the dispatch
stage into post_process_dlsch → generate_dl_mac_pdu, giving this stage access to the
full candidate context (per-LCID pending bytes, QoS, priority).
The function pointer receives the MAC instance, the candidate, the TBS available after
MAC CEs, and writes an output array lcid_alloc[NR_MAX_NUM_LCID] specifying the byte
budget per LCID. The MAC PDU builder then executes the plan: it iterates over LCs in
config order, requests up to lcid_alloc[lcid] bytes from RLC for each, and builds the
MAC subheaders. RLC may return fewer bytes than budgeted; the builder handles this
transparently.
Inputs: candidate->pending_bytes_per_lcid[], candidate->fiveQI, candidate->priority,
candidate->nssai, tbs_available.
Outputs: lcid_alloc[NR_MAX_NUM_LCID] — byte budget per LCID.
Not invoked for retransmissions (which reuse the stored transport block) or for phy-test mode (which fills random data). In these cases the candidate pointer is NULL.
Default Implementations
All function pointers are assigned in
main.c at startup.
The COMMIT_ALLOC / COMMIT_UL_ALLOC macros are defined in
mac_proto.h.
nr_dl_ri_pmi_select_default, nr_ul_ri_tpmi_select_default
DL reads rank and PMI from the UE's CSI report (csi_ri, csi_pm_index on
sched_ctrl). Retransmissions reuse rank/PMI from the original HARQ process.
UL reads rank and TPMI from SRS feedback (srs_feedback on sched_ctrl).
Both write cand->sched_p{d,u}sch.nrOfLayers and the respective precoder index.
nr_dl_tda_select_default, nr_ul_tda_select_default
Assigns the same TDA to all candidates in a slot (picks the first valid one for the
scheduled frame/slot). For UL retransmissions, calls check_ul_retx_feasibility to
verify TBS preservation under the new symbol/DMRS layout.
nr_dl_beam_select_default, nr_ul_beam_select_default
Uses OAI's beam management framework (NR_beam_info_t). For UL, allocates both a DCI
beam and a PUSCH beam. Candidates that fail beam allocation are compacted out.
nr_dl_mcs_select_default, nr_ul_mcs_select_default
BLER-based (when harq_round_max > 1): calls nr_adapt_mcs_from_bler for candidates
with a fresh BLER estimate; holds current MCS otherwise. SINR-based (when
harq_round_max == 1): maps measured PUSCH SINR to MCS via lookup table. In both
cases, persists to {dl,ul}_bler_stats.mcs.
nr_dl_proportional_fair, nr_ul_proportional_fair
Proportional-fair scheduler with three phases:
- Phase 1 — Retransmissions: find the largest free block >=
retx_rbSize. - Phase 2 — Minimal-grant UEs: in DL, targets UEs with no pending RLC data
(
pending_bytes == 0) that still need a TA command or beam-switch MAC CE. In UL, targets inactive UEs (sched_inactive) that need scheduling for TA/SR. Both get a minimum grant (min_rb). - Phase 3 — New data: sort by PF weight (
pending_bytes / avg_throughput), allocate the largest free block to each UE in order.
The UL policy also checks PHR (Power Headroom) and adjusts MCS/RBs accordingly.
All phases use COMMIT_ALLOC / COMMIT_UL_ALLOC to validate CCE (and PUCCH on DL).
nr_dl_lcid_alloc_default
Greedy fill: sets each LCID's budget to its full pending_bytes_per_lcid value. The
execution loop in generate_dl_mac_pdu then fills LCIDs in LC config order until the
TBS is exhausted, reproducing the original first-come-first-served behavior. A custom
implementation could use the candidate's QoS fields (fiveQI, priority, nssai)
to implement weighted fair queuing, strict priority with rate limiting, or slice-aware
allocation across LCIDs.
nr_dlsch_preprocessor, nr_ulsch_preprocessor
Top-level orchestrators that run the full pipeline above. In phy-test mode, replaced by
nr_preprocessor_phytest / nr_ul_preprocessor_phytest which bypass the staged pipeline.
Function Pointer Reference
DL
Field on gNB_MAC_INST |
Typedef | Default | Stage |
|---|---|---|---|
pre_processor_dl |
nr_pp_impl_dl |
nr_dlsch_preprocessor |
Orchestrator |
dl_ri_pmi_select |
nr_dl_ri_pmi_select_fn |
nr_dl_ri_pmi_select_default |
2. RI/PMI |
dl_beam_select |
nr_dl_beam_select_fn |
nr_dl_beam_select_default |
3. Beam |
dl_tda_select |
nr_dl_tda_select_fn |
nr_dl_tda_select_default |
4. TDA |
dl_mcs_select |
nr_dl_mcs_select_fn |
nr_dl_mcs_select_default |
5. MCS |
dl_rb_alloc |
nr_dl_rb_alloc_fn |
nr_dl_proportional_fair |
6. RB alloc |
dl_lcid_alloc |
nr_dl_lcid_alloc_fn |
nr_dl_lcid_alloc_default |
8. LCID alloc |
UL
Field on gNB_MAC_INST |
Typedef | Default | Stage |
|---|---|---|---|
pre_processor_ul |
nr_pp_impl_ul |
nr_ulsch_preprocessor |
Orchestrator |
ul_ri_tpmi_select |
nr_ul_ri_tpmi_select_fn |
nr_ul_ri_tpmi_select_default |
2. RI/TPMI |
ul_beam_select |
nr_ul_beam_select_fn |
nr_ul_beam_select_default |
3. Beam |
ul_tda_select |
nr_ul_tda_select_fn |
nr_ul_tda_select_default |
4. TDA |
ul_mcs_select |
nr_ul_mcs_select_fn |
nr_ul_mcs_select_default |
5. MCS |
ul_rb_alloc |
nr_ul_rb_alloc_fn |
nr_ul_proportional_fair |
6. RB alloc |
Key Data Structures
All defined in nr_mac_gNB.h.
Candidate (nr_{dl,ul}_candidate_t)
Flows through the pipeline accumulating decisions. Three sections:
Identity / scheduling state (set by collect, read-only after):
UE pointer, rnti, is_retx, retx_harq_pid, retx_rbSize, pending_bytes,
pending_bytes_per_lcid[NR_MAX_NUM_LCID], avg_throughput, bler, current_mcs,
max_mcs, bwp_start, bwp_size, fiveQI, priority, nssai.
CSI observations (set by collect, read-only after):
cqi (DL CQI), beam_rsrp[], beam_sinr[] (per-SSB L1-RSRP/SINR, INT16_MIN = no data).
gNB decisions (written by the named stage — some in sched_p{d,u}sch, some alloc_*):
sched_p{d,u}sch.nrOfLayers, .pm_index/.tpmi, .time_domain_allocation, .tda_info,
.mcs, .rbStart, .rbSize;
alloc_slbitmap, alloc_beam_idx, alloc_dci_beam_idx (UL),
alloc_cce_index, alloc_aggregation_level, alloc_sched_pdcch.
Sched Params (nr_{dl,ul}_sched_params_t)
Per-beam context passed to rb_alloc. Contains the MAC instance, slot info, VRB map,
available RBs, BLER thresholds, TDA info, and serving cell config.
Writing a Custom Scheduler
Replacing the RB allocation policy
Write a function matching the typedef:
int my_ul_rb_alloc(const nr_ul_sched_params_t *params,
nr_ul_candidate_t *candidates,
int n_candidates)
{
int n_scheduled = 0;
for (int i = 0; i < n_candidates; i++) {
nr_ul_candidate_t *cand = &candidates[i];
// ... decide rbStart, rbSize, mcs ...
// COMMIT_UL_ALLOC writes sched_pusch fields, validates CCE, sets cand->scheduled:
COMMIT_UL_ALLOC(params, cand, rbStart, rbSize, mcs, n_scheduled);
}
return n_scheduled;
}
Register it in openair2/LAYER2/NR_MAC_gNB/main.c:
RC.nrmac[i]->ul_rb_alloc = my_ul_rb_alloc;
Replacing the per-LCID allocation policy (DL)
Write a function matching the typedef:
void my_lcid_alloc(const gNB_MAC_INST *mac,
const nr_dl_candidate_t *candidate,
int tbs_available,
int lcid_alloc[NR_MAX_NUM_LCID])
{
memset(lcid_alloc, 0, NR_MAX_NUM_LCID * sizeof(int));
// ... use candidate->pending_bytes_per_lcid[], candidate->fiveQI,
// candidate->priority, tbs_available to decide budgets ...
for (int lcid = 0; lcid < NR_MAX_NUM_LCID; lcid++)
lcid_alloc[lcid] = my_budget_for(lcid);
}
Register it:
RC.nrmac[i]->dl_lcid_alloc = my_lcid_alloc;
Replacing other stages
Any function pointer can be replaced independently:
RC.nrmac[i]->ul_ri_tpmi_select = my_ri_tpmi_select;
RC.nrmac[i]->ul_mcs_select = my_mcs_select;
RC.nrmac[i]->ul_beam_select = my_beam_select;
Recommendations
- Always use
COMMIT_ALLOC/COMMIT_UL_ALLOCto accept an allocation. It writessched_p{d,u}schfields onto the candidate, validates CCE (and PUCCH on DL), and setscand->scheduled. - Do not mark the VRB map —
commit_alloc/commit_ul_allochandles that inside the macro. sched_p{d,u}sch.mcsis already set bymcs_selectwhen your policy runs. You may refine it downward (e.g. PHR); pass the final value toCOMMIT_ALLOC/COMMIT_UL_ALLOC.- Candidates are non-const: the policy is allowed to write
sched_p{d,u}schandalloc_*fields. Do not modify identity/state or CSI fields.