gpumemory.go 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. package synthetic
  2. import (
  3. "maps"
  4. "math"
  5. "strings"
  6. "time"
  7. "github.com/opencost/opencost/core/pkg/source"
  8. "github.com/opencost/opencost/modules/collector-source/pkg/metric"
  9. )
  10. // gpuFramebufferSample pairs the framebuffer used/free updates for one GPU
  11. // (or MIG instance) and container within a single scrape.
  12. type gpuFramebufferSample struct {
  13. used *metric.Update
  14. free *metric.Update
  15. }
  16. // GPUMemoryUsedRatioSynthesizer joins DCGM_FI_DEV_FB_USED and
  17. // DCGM_FI_DEV_FB_FREE within each scrape and synthesizes a per-sample
  18. // framebuffer occupancy ratio metric, used / (used + free). Joining per
  19. // scrape is what makes time-over-threshold memory pressure computable
  20. // downstream; post-aggregation joins cannot recover the per-sample ratio.
  21. type GPUMemoryUsedRatioSynthesizer struct {
  22. byDevice map[string]*gpuFramebufferSample
  23. }
  24. // NewGPUMemoryUsedRatioSynthesizer creates a synthesizer producing
  25. // OpencostGPUMemoryUsedRatio updates from the DCGM framebuffer metrics.
  26. func NewGPUMemoryUsedRatioSynthesizer() *GPUMemoryUsedRatioSynthesizer {
  27. return &GPUMemoryUsedRatioSynthesizer{
  28. byDevice: make(map[string]*gpuFramebufferSample),
  29. }
  30. }
  31. // gpuDeviceKey identifies one GPU (or MIG instance) attached to one
  32. // container: dcgm-exporter emits one used/free series per such pairing.
  33. func gpuDeviceKey(labels map[string]string) string {
  34. return strings.Join([]string{
  35. labels[source.UUIDLabel],
  36. labels[source.MIGInstanceLabel],
  37. labels[source.PodUIDLabel],
  38. labels[source.ContainerLabel],
  39. }, "|")
  40. }
  41. // Process records framebuffer used/free updates; all other metrics are
  42. // ignored.
  43. func (s *GPUMemoryUsedRatioSynthesizer) Process(t time.Time, update *metric.Update) {
  44. var sample *gpuFramebufferSample
  45. switch update.Name {
  46. case metric.DCGMFIDEVFBUSED, metric.DCGMFIDEVFBFREE:
  47. key := gpuDeviceKey(update.Labels)
  48. if _, ok := s.byDevice[key]; !ok {
  49. s.byDevice[key] = &gpuFramebufferSample{}
  50. }
  51. sample = s.byDevice[key]
  52. default:
  53. return
  54. }
  55. if update.Name == metric.DCGMFIDEVFBUSED {
  56. sample.used = update
  57. } else {
  58. sample.free = update
  59. }
  60. }
  61. // Synthesize emits one occupancy ratio update per device that reported both
  62. // framebuffer metrics this scrape. Devices missing either half, or
  63. // reporting a non-positive or non-finite total, emit nothing.
  64. func (s *GPUMemoryUsedRatioSynthesizer) Synthesize() []metric.Update {
  65. var updates []metric.Update
  66. for _, sample := range s.byDevice {
  67. if sample.used == nil || sample.free == nil {
  68. continue
  69. }
  70. used := sample.used.Value
  71. free := sample.free.Value
  72. total := used + free
  73. // Both components must be individually non-negative: checking only
  74. // the total would let a corrupt negative FB_FREE (e.g. used=100,
  75. // free=-50) through and produce a ratio above 1, escaping the
  76. // documented [0, 1] occupancy range. (Code review finding.)
  77. if math.IsNaN(total) || math.IsInf(total, 0) || total <= 0 || used < 0 || free < 0 {
  78. continue
  79. }
  80. updates = append(updates, metric.Update{
  81. Name: metric.OpencostGPUMemoryUsedRatio,
  82. Labels: maps.Clone(sample.used.Labels),
  83. Value: used / total,
  84. })
  85. }
  86. return updates
  87. }
  88. // Clear resets the per-scrape state.
  89. func (s *GPUMemoryUsedRatioSynthesizer) Clear() {
  90. s.byDevice = make(map[string]*gpuFramebufferSample)
  91. }