Files
openairinterface5g/doc/MAC/scheduler-architecture.md
Maxime 0c782e3ee3 Add scheduler architecture documentation
Assisted By Claude Code:Opus-4.6

Signed-off-by: Maxime Elkael <m.elkael@northeastern.edu>
2026-05-18 16:44:20 -04:00

22 KiB
Raw Permalink Blame History

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 27 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:

  1. Identity / scheduling state — set by collect, read-only afterwards: UE pointer, rnti, HARQ state, pending bytes, BLER estimate, current MCS, BWP geometry, QoS.
  2. CSI observations — set by collect: cqi (DL), beam_rsrp[], beam_sinr[] (per-SSB).
  3. gNB decisions — written by the stage indicated below. Some live in the embedded sched_pdsch / sched_pusch sub-struct, others are alloc_-prefixed fields on the candidate itself:
    • sched_p{d,u}sch.nrOfLayers, .pm_index (DL) / .tpmi (UL) — set by ri_pmi/tpmi_select
    • sched_p{d,u}sch.time_domain_allocation, .tda_info, alloc_slbitmap — set by tda_select
    • alloc_beam_idx, alloc_dci_beam_idx (UL), etc. — set by beam_select
    • sched_p{d,u}sch.mcs — set by mcs_select
    • sched_p{d,u}sch.rbStart, .rbSize — set by rb_alloc
    • alloc_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_dlschgenerate_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_ALLOC to accept an allocation. It writes sched_p{d,u}sch fields onto the candidate, validates CCE (and PUCCH on DL), and sets cand->scheduled.
  • Do not mark the VRB mapcommit_alloc / commit_ul_alloc handles that inside the macro.
  • sched_p{d,u}sch.mcs is already set by mcs_select when your policy runs. You may refine it downward (e.g. PHR); pass the final value to COMMIT_ALLOC / COMMIT_UL_ALLOC.
  • Candidates are non-const: the policy is allowed to write sched_p{d,u}sch and alloc_* fields. Do not modify identity/state or CSI fields.