cpupick.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. // Package cpupick chooses which CPUs the tun reader threads pin to when the
  2. // operator has not chosen for us (tun.cpu_affinity). The stock spread —
  3. // allowed[i] for routine i — has two failure modes this package exists to fix:
  4. //
  5. // - every co-located nebula starts its spread at allowed[0], so N instances
  6. // on one box stack their readers onto the same cores, and allowed[0] is
  7. // usually CPU 0, the core housekeeping and default IRQ affinity already
  8. // favor;
  9. // - on heterogeneous CPUs (ARM big.LITTLE, Intel P/E hybrids, AMD compact
  10. // cores) low IDs are not necessarily fast cores, and pinning an encrypt
  11. // thread to an efficiency core caps that queue's throughput.
  12. //
  13. // Default instead returns a preference-ordered pin list: the allowed set
  14. // filtered to performance cores (when the platform distinguishes them and
  15. // enough remain for every routine), confined to a single NUMA node and spread
  16. // across distinct physical cores when the topology permits, CPU 0's physical
  17. // core demoted to last resort, and the order rotated by a stable per-instance
  18. // key so co-located instances spread instead of stacking.
  19. package cpupick
  20. import (
  21. "log/slog"
  22. "github.com/slackhq/nebula/util"
  23. )
  24. // topology is the slice of machine layout arrange consults: the NUMA node
  25. // and the physical core behind each candidate CPU, plus which core CPU 0
  26. // lives on (zeroCore, -1 when unknown — tracked separately because CPU 0's
  27. // SMT sibling deserves demotion even when CPU 0 itself isn't a candidate).
  28. // Probed from sysfs on Linux; flatTopology stands in when the platform can't
  29. // say, which turns every topology rule into a no-op rather than a wrong
  30. // answer.
  31. type topology struct {
  32. nodeOf map[int]int
  33. coreOf map[int]int
  34. zeroCore int
  35. }
  36. // flatTopology places every CPU on node 0 and on a physical core of its own.
  37. func flatTopology(cpus []int) topology {
  38. t := topology{
  39. nodeOf: make(map[int]int, len(cpus)),
  40. coreOf: make(map[int]int, len(cpus)),
  41. zeroCore: -1,
  42. }
  43. for i, c := range cpus {
  44. t.nodeOf[c] = 0
  45. t.coreOf[c] = i
  46. if c == 0 {
  47. t.zeroCore = i
  48. }
  49. }
  50. return t
  51. }
  52. // Default computes the pin order for `routines` tun readers. key is any
  53. // stable per-instance value; the bound UDP port is ideal — distinct across
  54. // co-located instances, stable across restarts so benchmark runs stay
  55. // comparable. Returns nil when there is nothing useful to say (no affinity
  56. // support on this platform, lookup failure); callers keep their existing
  57. // fallback spread.
  58. func Default(routines int, key uint64, l *slog.Logger) []int {
  59. allowed, err := util.AllowedCPUs()
  60. if err != nil || len(allowed) == 0 {
  61. return nil
  62. }
  63. perf, signal := perfCPUs(allowed)
  64. cands := pickCandidates(allowed, perf, routines)
  65. if len(cands) == 0 {
  66. return nil
  67. }
  68. if len(perf) < routines {
  69. signal = ""
  70. }
  71. cpus := arrange(cands, readTopology(cands), routines, splitmix64(key))
  72. if l != nil {
  73. l.Info("chose default pin CPUs for tun readers",
  74. "cpus", cpus[:min(routines, len(cpus))],
  75. "perfSignal", signal)
  76. }
  77. return cpus
  78. }
  79. // pickCandidates applies the enough-for-everyone guard: a perf filter that
  80. // leaves fewer candidates than routines is discarded — giving every reader
  81. // its own (possibly slow) core beats stacking two readers on a fast one.
  82. func pickCandidates(allowed, perf []int, routines int) []int {
  83. if len(perf) < routines {
  84. return allowed
  85. }
  86. return perf
  87. }
  88. // arrange turns the candidate set into the final pin order:
  89. //
  90. // 1. NUMA: when at least one node holds enough candidates for every
  91. // routine, confine to one such node, chosen by the instance hash. The
  92. // readers share hostmap and cipher state, so splitting one instance
  93. // across nodes taxes every packet — and co-located instances that hash
  94. // to different nodes stop competing entirely. When no node is big
  95. // enough, span nodes rather than stack readers.
  96. // 2. Rotate the preferred candidates by the hash so instances spread.
  97. // 3. SMT: emit one thread per physical core before any of their siblings —
  98. // two encrypt threads on one core split its execution units. Siblings
  99. // still follow for the routines > cores case.
  100. // 4. CPU 0's whole physical core goes last: housekeeping and default IRQ
  101. // noise on CPU 0 bleeds into its SMT sibling too. Within that tail the
  102. // sibling precedes CPU 0 itself, which only catches the bleed-through.
  103. //
  104. // The rotation happens before the SMT pass so each instance's one-per-core
  105. // walk also starts at a different core, and CPU 0's core is excluded from
  106. // the rotation so no hash value can put it back at the front.
  107. func arrange(cands []int, topo topology, routines int, h uint64) []int {
  108. byNode := map[int][]int{}
  109. var nodes []int
  110. for _, c := range cands {
  111. n := topo.nodeOf[c]
  112. if _, ok := byNode[n]; !ok {
  113. nodes = append(nodes, n)
  114. }
  115. byNode[n] = append(byNode[n], c)
  116. }
  117. var eligible []int
  118. for _, n := range nodes {
  119. if len(byNode[n]) >= routines {
  120. eligible = append(eligible, n)
  121. }
  122. }
  123. if len(eligible) > 0 {
  124. cands = byNode[eligible[int(h%uint64(len(eligible)))]]
  125. }
  126. // Split off CPU 0's core: its siblings tail the list, CPU 0 tails them.
  127. preferred := make([]int, 0, len(cands))
  128. var zeroTail []int
  129. hasZero := false
  130. for _, c := range cands {
  131. switch {
  132. case c == 0:
  133. hasZero = true
  134. case topo.zeroCore >= 0 && topo.coreOf[c] == topo.zeroCore:
  135. zeroTail = append(zeroTail, c)
  136. default:
  137. preferred = append(preferred, c)
  138. }
  139. }
  140. if hasZero {
  141. zeroTail = append(zeroTail, 0)
  142. }
  143. if len(preferred) == 0 {
  144. return zeroTail // CPU 0's core is all we have
  145. }
  146. // The node pick consumed the low hash bits; rotate by the high ones so
  147. // the two choices stay independent.
  148. off := int((h >> 32) % uint64(len(preferred)))
  149. rot := make([]int, 0, len(preferred))
  150. rot = append(rot, preferred[off:]...)
  151. rot = append(rot, preferred[:off]...)
  152. seenCore := make(map[int]bool, len(rot))
  153. out := make([]int, 0, len(cands))
  154. var siblings []int
  155. for _, c := range rot {
  156. g := topo.coreOf[c]
  157. if seenCore[g] {
  158. siblings = append(siblings, c)
  159. continue
  160. }
  161. seenCore[g] = true
  162. out = append(out, c)
  163. }
  164. out = append(out, siblings...)
  165. out = append(out, zeroTail...)
  166. return out
  167. }
  168. // splitmix64 decorrelates instance keys before the selection modulos: ports
  169. // on one box often share spacing (4242/4243, or round steps like +1000) that
  170. // raw key%len arithmetic would fold onto the same offset.
  171. func splitmix64(x uint64) uint64 {
  172. x += 0x9e3779b97f4a7c15
  173. x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
  174. x = (x ^ (x >> 27)) * 0x94d049bb133111eb
  175. return x ^ (x >> 31)
  176. }