现代各种处理器采用了许多不同的技术,但从物理结构上,CPU的层次结构却可以被统一的表示如下: 最底层为超线程层,此技术为Intel公司研发,目前在ARM等其他架构处理器中并未采用。超线程技术充分利用空闲CPU资源,在单一时间内可以让一个物理核心同时处理两个线程的工作。因此,在开启超线程的4物理核心的机器上,往往可以看到存在有8个逻辑CPU。 再往上的层次就是物理核心层。单独的一个物理核心的标志往往是其独占的私有缓存(Cache)。通常在两级缓存的情况下,每个物理核心独享L1 Cache,L2 Cache则为几个物理核心共享;在三级缓存的情况下,L1与L2为私有,L3为共享。当然前面说的是通常情况,不能武断地认为二级缓存与三级缓存的情况都是统一的。 继续往上层走就是处理器层级,多个物理核心可以共享最后一级缓存(LLC),这多个物理核心就被称为是一个Cluster或者Socket。芯片厂商会把多个物理核心(Core)封装到一个片(Chip)上,主板上会留有插槽,一个插槽可以插一个Chip。 最上层就到了NUMA的概念了,为了减少对内存总线的访问竞争,可以将CPU分属于不同的Node节点,通常情况下,CPU只访问Node内的memory。 在不考虑NUMA的情况下,CPU所属层次从高到低可以表示为Cluster ---> Core ---> Threads。 了解CPU的层次结构有什么作用呢?以服务器为例,负载均衡是调度器常要考虑的问题。负载均衡在进行任务迁移的时候,把任务移动到哪个CPU上是重中之重。如果开启了超线程,那么移动到超线程层次的兄弟CPU上,被迁移后,原Cache上进程的数据仍然可用,可以说是最优解;如果移动到物理核心层的兄弟CPU上,则LLC上的原先该进程的数据仍然可以被访问到。可以说,迁移到离原CPU层次更近的兄弟CPU上带来的影响更小。 以手机等小型设备为例,功耗是核心问题。尤其是ARM多采用big core和little core的组合结构,即一个Cluster的物理核心是功耗和性能都比较高的CPU,另一个Cluster的物理核心是功耗和性能都比较低的CPU。这时候任务迁移,除了要考虑层次的兄弟临近关系,还要考虑移动到哪个CPU上可以保证功耗和性能的平衡问题。 那么,CPU的层次结构在Linux kernel中是如何表示的呢? 我们以ARM64下CPU的初始化开始入手。ARM64平台下CPU的初始化需要读取DTS(Device Tree Source,设备树)文件。 【配套的Linux内核技术教程文档资料】可以私信我【内核】免费获取。 随意挑选arm64目录下一个处理器的dtsi文件的部分为例: cpus { #address-cells = <1>; #size-cells = <0>; cpu-map { cluster0 { core0 { cpu = <&cpu0>; }; ...... }; ...... }; cpu0: cpu@20000 { device_type = 'cpu'; compatible = 'arm,cortex-a57'; reg = <0x20000>; enable-method = 'psci'; next-level-cache = <&cluster0_l2>; }; ...... cluster0_l2: l2-cache0 { compatible = 'cache'; }; ...... }; 以上是DTS文件中CPU定义的一个统一的格式。start_kernel --> setup_arch --> smp_init_cpus。 smp_init_cpus在系统启动的时候读取DTS文件中CPU信息,获得CPU数量,并调用smp_cpu_setup函数进行possible位图的设置。
这里先了解一下内核中存在的四种CPU的状态表示,分别是possible、online、present和active。 typedef struct cpumask { DECLARE_BITMAP(bits, NR_CPUS); } cpumask_t; #define DECLARE_BITMAP(name,bits) \ unsigned long name[BITS_TO_LONGS(bits)] extern struct cpumask __cpu_possible_mask; extern struct cpumask __cpu_online_mask; extern struct cpumask __cpu_present_mask; extern struct cpumask __cpu_active_mask; #define cpu_possible_mask ((const struct cpumask *)&__cpu_possible_mask) #define cpu_online_mask ((const struct cpumask *)&__cpu_online_mask) #define cpu_present_mask ((const struct cpumask *)&__cpu_present_mask) #define cpu_active_mask ((const struct cpumask *)&__cpu_active_mask) 可以从smp_cpu_setup函数看出,如果一个CPU编号对应的struct cpu_operations被建立成功,并且其对应的init函数没有执行失败,则将该CPU标记为possible。
除了上述的几个成员函数,可以看到其他成员函数是分别和CPU热插拔和CPUIDLE相关的电源操作,kernel抽象出了struct cpu_operations这个结构体,将启动、热插拔、空闲态接口统一起来,交给各个体系结构自己完成。 start_kernel --> arch_call_rest_init --> rest_init --> kernel_init --> kernel_init_freezable --> smp_prepare_cpus 接下来一直到smp_prepare_cpus函数,才到设置present位图的时候。 以下是smp_prepare_cpus的部分代码: void __init smp_prepare_cpus(unsigned int max_cpus) { const struct cpu_operations *ops; int err; unsigned int cpu; unsigned int this_cpu; ...... for_each_possible_cpu(cpu) { per_cpu(cpu_number, cpu) = cpu; if (cpu == smp_processor_id()) continue; ops = get_cpu_ops(cpu); if (!ops) continue; err = ops->cpu_prepare(cpu); if (err) continue; set_cpu_present(cpu, true); numa_store_cpu_info(cpu); } } 前面看到,possible位图的设置依赖于cpu_operations结构体的初始化以及是否读取到正确的CPU信息;那这里present位图的设置则依赖于cpu_prepare的执行结果,也就是检测对应CPU是否可以启动。 kernel_init_freezable --> smp_init --> bringup_nonboot_cpus。
cpu_up --> _cpu_up --> cpuhp_up_callbacks --> cpuhp_invoke_callback --> bringup_cpu --> __cpu_up --> boot_secondary cpu_up,这里可以这么简单的理解,最开始系统中只有一个CPU,当代码执行到这里的时候,开始启动其他的CPU,其他CPU的启动过程,首先是需要复制主CPU的0号线程(即空闲线程),然后去执行热插拔相关的回调函数,一直执行到boot_secondary,在这里最终会执行到struct cpu_operations中的回调函数cpu_boot。 static int boot_secondary(unsigned int cpu, struct task_struct *idle) { const struct cpu_operations *ops = get_cpu_ops(cpu); if (ops->cpu_boot) return ops->cpu_boot(cpu); return -EOPNOTSUPP; } 而通常在struct cpu_operations中的成员cpu_boot的执行中,会执行到secondary_entry --> secondary_startup --> __secondary_switched --> secondary_start_kernel。 而在secondary_start_kernel中会调用set_cpu_online函数设置online位图。 active这个位图的修改函数定义在CPU热插拔的回调函数数组中,也就是struct cpuhp_step cpuhp_hp_states[]中,该位图服务于调度器,在开启热插拔的情况下,调度器需要实时监控CPU热插拔的每个信息,因此需要该位图实时将某个CPU移除或添加。 说完了四种位图,突然发现还没有说到CPU的层次结构在代码上的表示,CPU的层次结构使用一个结构体struct cpu_topology来表示。
其实,代码上CPU的层次结构的赋值代码与CPU启动的时间基本一致,主CPU的赋值是在smp_prepare_cpus函数中;其余CPU是在secondary_start_kernel函数中。 直接来看cpu_topology结构体的赋值函数store_cpu_topology。在调用该函数之前,已经在init_cpu_topology函数中进行过cpu_topology数组的初始化了,其中thread_id、core_id、package_id和llc_id皆初始化为-1。 void store_cpu_topology(unsigned int cpuid) { struct cpu_topology *cpuid_topo = &cpu_topology[cpuid]; u64 mpidr; if (cpuid_topo->package_id != -1) goto topology_populated; mpidr = read_cpuid_mpidr(); /* Uniprocessor systems can rely on default topology values */ if (mpidr & MPIDR_UP_BITMASK) return; cpuid_topo->thread_id = -1; cpuid_topo->core_id = cpuid; cpuid_topo->package_id = cpu_to_node(cpuid); pr_debug('CPU%u: cluster %d core %d thread %d mpidr %#016llx\n', cpuid, cpuid_topo->package_id, cpuid_topo->core_id, cpuid_topo->thread_id, mpidr); topology_populated: update_siblings_masks(cpuid); } 以arm64为例,thread_id应该是超线程情况下才有意义,这里不考虑。core_id等同于物理核心id,也就是逻辑核心id,二者一致,package_id指向了其所属的NUMA的Node号。update_sibling_masks则用于更新用于表示各个层级的兄弟关系。 说了这么多,一直在说CPU的物理层次结构在代码上的表示,却还没有说到调度中CPU物理结构的参与。 这里关注kernel_init_freezable函数有这样一段代码:
前面已经了解到smp_init函数执行完成时,各个次CPU已经启动完成,此时进入sched_init_smp函数。 sched_init_smp --> sched_init_domains --> build_sched_domains static int build_sched_domains(const struct cpumask *cpu_map, struct sched_domain_attr *attr) { enum s_alloc alloc_state = sa_none; struct sched_domain *sd; struct s_data d; struct rq *rq = NULL; int i, ret = -ENOMEM; struct sched_domain_topology_level *tl_asym; bool has_asym = false; if (WARN_ON(cpumask_empty(cpu_map))) goto error; // 核心函数1,此时的cpu_map是active的位图 alloc_state = __visit_domain_allocation_hell(&d, cpu_map); if (alloc_state != sa_rootdomain) goto error; tl_asym = asym_cpu_capacity_level(cpu_map); /* Set up domains for CPUs specified by the cpu_map: */ for_each_cpu(i, cpu_map) { struct sched_domain_topology_level *tl; int dflags = 0; sd = NULL; for_each_sd_topology(tl) { if (tl == tl_asym) { dflags |= SD_ASYM_CPUCAPACITY; has_asym = true; } if (WARN_ON(!topology_span_sane(tl, cpu_map, i))) goto error; // 核心函数2 sd = build_sched_domain(tl, cpu_map, attr, sd, dflags, i); // 最底层的时候,进行赋值操作 if (tl == sched_domain_topology) *per_cpu_ptr(d.sd, i) = sd; if (tl->flags & SDTL_OVERLAP) sd->flags |= SD_OVERLAP; if (cpumask_equal(cpu_map, sched_domain_span(sd))) break; } } /* Build the groups for the domains */ for_each_cpu(i, cpu_map) { for (sd = *per_cpu_ptr(d.sd, i); sd; sd = sd->parent) { sd->span_weight = cpumask_weight(sched_domain_span(sd)); if (sd->flags & SD_OVERLAP) { if (build_overlap_sched_groups(sd, i)) goto error; } else { // 核心函数3 if (build_sched_groups(sd, i)) goto error; } } } /* Calculate CPU capacity for physical packages and nodes */ for (i = nr_cpumask_bits-1; i >= 0; i--) { if (!cpumask_test_cpu(i, cpu_map)) continue; for (sd = *per_cpu_ptr(d.sd, i); sd; sd = sd->parent) { claim_allocations(i, sd); init_sched_groups_capacity(i, sd); } } /* Attach the domains */ rcu_read_lock(); for_each_cpu(i, cpu_map) { rq = cpu_rq(i); sd = *per_cpu_ptr(d.sd, i); /* Use READ_ONCE()/WRITE_ONCE() to avoid load/store tearing: */ if (rq->cpu_capacity_orig > READ_ONCE(d.rd->max_cpu_capacity)) WRITE_ONCE(d.rd->max_cpu_capacity, rq->cpu_capacity_orig); cpu_attach_domain(sd, d.rd, i); } rcu_read_unlock(); if (has_asym) static_branch_inc_cpuslocked(&sched_asym_cpucapacity); if (rq && sched_debug_enabled) { pr_info('root domain span: %*pbl (max cpu_capacity = %lu)\n', cpumask_pr_args(cpu_map), rq->rd->max_cpu_capacity); } ret = 0; error: __free_domain_allocs(&d, alloc_state, cpu_map); return ret; } 首先,分析第1个核心函数
首先进入__sdt_alloc函数。 static int __sdt_alloc(const struct cpumask *cpu_map) { struct sched_domain_topology_level *tl; int j; for_each_sd_topology(tl) { struct sd_data *sdd = &tl->data; sdd->sd = alloc_percpu(struct sched_domain *); if (!sdd->sd) return -ENOMEM; sdd->sds = alloc_percpu(struct sched_domain_shared *); if (!sdd->sds) return -ENOMEM; sdd->sg = alloc_percpu(struct sched_group *); if (!sdd->sg) return -ENOMEM; sdd->sgc = alloc_percpu(struct sched_group_capacity *); if (!sdd->sgc) return -ENOMEM; for_each_cpu(j, cpu_map) { struct sched_domain *sd; struct sched_domain_shared *sds; struct sched_group *sg; struct sched_group_capacity *sgc; sd = kzalloc_node(sizeof(struct sched_domain) + cpumask_size(), GFP_KERNEL, cpu_to_node(j)); if (!sd) return -ENOMEM; *per_cpu_ptr(sdd->sd, j) = sd; sds = kzalloc_node(sizeof(struct sched_domain_shared), GFP_KERNEL, cpu_to_node(j)); if (!sds) return -ENOMEM; *per_cpu_ptr(sdd->sds, j) = sds; sg = kzalloc_node(sizeof(struct sched_group) + cpumask_size(), GFP_KERNEL, cpu_to_node(j)); if (!sg) return -ENOMEM; sg->next = sg; *per_cpu_ptr(sdd->sg, j) = sg; sgc = kzalloc_node(sizeof(struct sched_group_capacity) + cpumask_size(), GFP_KERNEL, cpu_to_node(j)); if (!sgc) return -ENOMEM; #ifdef CONFIG_SCHED_DEBUG sgc->id = j; #endif *per_cpu_ptr(sdd->sgc, j) = sgc; } } return 0; } 在这个函数中我们接触到第一个比较关键的数据结构struct
可以看到,struct struct 这里简单看下物理核心层的mask成员cpu_coregroup_mask。 const struct cpumask *cpu_coregroup_mask(int cpu) { const cpumask_t *core_mask = cpumask_of_node(cpu_to_node(cpu)); /* Find the smaller of NUMA, core or LLC siblings */ if (cpumask_subset(&cpu_topology[cpu].core_sibling, core_mask)) { /* not numa in package, lets use the package siblings */ core_mask = &cpu_topology[cpu].core_sibling; } if (cpu_topology[cpu].llc_id != -1) { if (cpumask_subset(&cpu_topology[cpu].llc_sibling, core_mask)) core_mask = &cpu_topology[cpu].llc_sibling; } return core_mask; } 可以看到,本质上是利用cpu_topology数组来获得core_sibling的值,到这里就和物理层级联系起来了。
分析__sdt_alloc,可以看到为每个层次的struct 接下来,继续分析build_sched_domains的第2个核心函数build_sched_domain,该函数在每个CPU的每个层次上都要去调用一次。 static struct sched_domain *build_sched_domain(struct sched_domain_topology_level *tl, const struct cpumask *cpu_map, struct sched_domain_attr *attr, struct sched_domain *child, int dflags, int cpu) { struct sched_domain *sd = sd_init(tl, cpu_map, child, dflags, cpu); if (child) { sd->level = child->level + 1; sched_domain_level_max = max(sched_domain_level_max, sd->level); child->parent = sd; if (!cpumask_subset(sched_domain_span(child), sched_domain_span(sd))) { pr_err('BUG: arch topology borken\n'); #ifdef CONFIG_SCHED_DEBUG pr_err(' the %s domain not a subset of the %s domain\n', child->name, sd->name); #endif /* Fixup, ensure @sd has at least @child CPUs. */ cpumask_or(sched_domain_span(sd), sched_domain_span(sd), sched_domain_span(child)); } } set_domain_attribute(sd, attr); return sd; } 其中sd_init函数完成对应
这里可以看到,sched_domain中的成员往往是与调度中负载均衡操作相关的一些字段,例如min_interval是最小的load balance间隔,max_interval是最大的load balance间隔等等。 这里应该注意,sched_domain的父子关系,高层级的sched_domain是低层级的sched_domain的parent,低层级的sched_domain是高层级的child。sched_domain的span为自己该层级的兄弟CPU以及下面几个层级的兄弟CPU的并集。 创建完sched_domain之后,开始从CPU开始由低到高依次建立sched_group,这就是第3个核心函数,build_sched_groups。 static int build_sched_groups(struct sched_domain *sd, int cpu) { struct sched_group *first = NULL, *last = NULL; struct sd_data *sdd = sd->private; const struct cpumask *span = sched_domain_span(sd); struct cpumask *covered; int i; lockdep_assert_held(&sched_domains_mutex); covered = sched_domains_tmpmask; cpumask_clear(covered); for_each_cpu_wrap(i, span, cpu) { struct sched_group *sg; if (cpumask_test_cpu(i, covered)) continue; sg = get_group(i, sdd); cpumask_or(covered, covered, sched_group_span(sg)); if (!first) first = sg; if (last) last->next = sg; last = sg; } last->next = first; sd->groups = first; return 0; } build_sched_groups函数的核心是get_group函数。
可以从get_group函数看出来,如果是最底层的sched_domain,即不存在child,那么get_group中建立的sched_group是该CPU独有的。而如果是非最底层的sched_domain,那么get_group中建立的sched_group是其child的span成员共享的。这里注意,sched_group_capacity和sched_group两位一体,表示该调度组的计算能力。 到这里,调度域和调度组的关系基本可以理清楚。在CPU层次结构上,CPU在每个层次都有一个调度域,该CPU在高层次的调度域与该CPU在低层次的调度域互成父子关系。 而调度域下皆有一个调度组,调度组是呈链表组织的。一个调度域将其调度域中所有CPU按照兄弟关系分成不同的调度组,然后以链表形式组织起来,一个调度域中的所有CPU在该CPU层次上是共享这个调度组的。 更新完成sched_group_capacity之后,最后一部分cpu_attach_domain --> rq_attach_root。 rq_attach_root中有这样的一行代码:rq->rd = rd;。至此,从rq --> root_domain --> sched_domain的关系已经建立完成。 |
|