Refactor topo viz to do as much computation upfront & use faster data structures

Now all data loading happens in the TopoViz component as well as
computation of resource proportions.

Allocation selection state is also managed centrally uses a dedicated
structure indexed by group key (job id and task group name). This way
allocations don't need to be scanned at the node level, which is O(n) at
the best (assuming no ember overhead on recomputes).
This commit is contained in:
Michael Lange 2020-09-23 18:10:11 -07:00
parent 7d75421a75
commit 0ab6b31cab
6 changed files with 157 additions and 112 deletions

View File

@ -1,7 +1,8 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action, set } from '@ember/object';
import { run } from '@ember/runloop'; import { run } from '@ember/runloop';
import { task } from 'ember-concurrency';
import { scaleLinear } from 'd3-scale'; import { scaleLinear } from 'd3-scale';
import { extent } from 'd3-array'; import { extent } from 'd3-array';
import RSVP from 'rsvp'; import RSVP from 'rsvp';
@ -10,6 +11,7 @@ export default class TopoViz extends Component {
@tracked heightScale = null; @tracked heightScale = null;
@tracked isLoaded = false; @tracked isLoaded = false;
@tracked element = null; @tracked element = null;
@tracked topology = {};
@tracked activeAllocation = null; @tracked activeAllocation = null;
@tracked activeEdges = []; @tracked activeEdges = [];
@ -22,17 +24,88 @@ export default class TopoViz extends Component {
return this.activeAllocation && this.activeAllocation.belongsTo('job').id(); return this.activeAllocation && this.activeAllocation.belongsTo('job').id();
} }
get datacenters() { dataForNode(node) {
const datacentersMap = this.args.nodes.reduce((datacenters, node) => { return {
if (!datacenters[node.datacenter]) datacenters[node.datacenter] = []; node,
datacenters[node.datacenter].push(node); datacenter: node.datacenter,
memory: node.resources.memory,
cpu: node.resources.cpu,
allocations: [],
};
}
dataForAllocation(allocation, node) {
const jobId = allocation.belongsTo('job').id();
return {
allocation,
node,
jobId,
groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
memory: allocation.resources.memory,
cpu: allocation.resources.cpu,
memoryPercent: allocation.resources.memory / node.memory,
cpuPercent: allocation.resources.cpu / node.cpu,
isSelected: false,
};
}
@task(function*() {
const nodes = this.args.nodes;
const allocations = this.args.allocations;
// Nodes are probably partials and we'll need the resources on them
// TODO: this is an API update waiting to happen.
yield RSVP.all(nodes.map(node => (node.isPartial ? node.reload() : RSVP.resolve(node))));
// Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment
const nodeContainers = [];
const nodeIndex = {};
nodes.forEach(node => {
const container = this.dataForNode(node);
nodeContainers.push(container);
nodeIndex[node.id] = container;
});
// Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation
// index keyed off of job and task group
const allocationIndex = {};
allocations.forEach(allocation => {
const nodeId = allocation.belongsTo('node').id();
const nodeContainer = nodeIndex[nodeId];
if (!nodeContainer)
throw new Error(`Node ${nodeId} for alloc ${allocation.id} not in index???`);
const allocationContainer = this.dataForAllocation(allocation, nodeContainer);
nodeContainer.allocations.push(allocationContainer);
const key = allocationContainer.groupKey;
if (!allocationIndex[key]) allocationIndex[key] = [];
allocationIndex[key].push(allocationContainer);
});
// Group nodes into datacenters
const datacentersMap = nodeContainers.reduce((datacenters, nodeContainer) => {
if (!datacenters[nodeContainer.datacenter]) datacenters[nodeContainer.datacenter] = [];
datacenters[nodeContainer.datacenter].push(nodeContainer);
return datacenters; return datacenters;
}, {}); }, {});
return Object.keys(datacentersMap) // Turn hash of datacenters into a sorted array
const datacenters = Object.keys(datacentersMap)
.map(key => ({ name: key, nodes: datacentersMap[key] })) .map(key => ({ name: key, nodes: datacentersMap[key] }))
.sortBy('name'); .sortBy('name');
}
const topology = {
datacenters,
allocationIndex,
selectedKey: null,
heightScale: scaleLinear()
.range([15, 40])
.domain(extent(nodeContainers.mapBy('memory'))),
};
this.topology = topology;
})
buildTopology;
@action @action
async loadNodes() { async loadNodes() {
@ -81,11 +154,37 @@ export default class TopoViz extends Component {
if (this.activeAllocation === allocation) { if (this.activeAllocation === allocation) {
this.activeAllocation = null; this.activeAllocation = null;
this.activeEdges = []; this.activeEdges = [];
if (this.topology.selectedKey) {
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (selectedAllocations) {
selectedAllocations.forEach(allocation => {
set(allocation, 'isSelected', false);
});
}
set(this.topology, 'selectedKey', null);
}
} else { } else {
this.activeAllocation = allocation; this.activeAllocation = allocation;
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (selectedAllocations) {
selectedAllocations.forEach(allocation => {
set(allocation, 'isSelected', false);
});
}
set(this.topology, 'selectedKey', allocation.groupKey);
const newAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (newAllocations) {
newAllocations.forEach(allocation => {
set(allocation, 'isSelected', true);
});
}
this.computedActiveEdges(); this.computedActiveEdges();
} }
if (this.args.onAllocationSelect) this.args.onAllocationSelect(this.activeAllocation); if (this.args.onAllocationSelect)
this.args.onAllocationSelect(this.activeAllocation && this.activeAllocation.allocation);
} }
@action @action
@ -93,7 +192,7 @@ export default class TopoViz extends Component {
// Wait a render cycle // Wait a render cycle
run.next(() => { run.next(() => {
const activeEl = this.element.querySelector( const activeEl = this.element.querySelector(
`[data-allocation-id="${this.activeAllocation.id}"]` `[data-allocation-id="${this.activeAllocation.allocation.id}"]`
); );
const selectedAllocations = this.element.querySelectorAll('.memory .bar.is-selected'); const selectedAllocations = this.element.querySelectorAll('.memory .bar.is-selected');
const activeBBox = activeEl.getBoundingClientRect(); const activeBBox = activeEl.getBoundingClientRect();

View File

@ -1,19 +1,13 @@
import RSVP from 'rsvp';
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
export default class TopoVizNode extends Component { export default class TopoVizDatacenter extends Component {
@tracked scheduledAllocations = []; @tracked scheduledAllocations = [];
@tracked aggregatedNodeResources = { cpu: 0, memory: 0 }; @tracked aggregatedNodeResources = { cpu: 0, memory: 0 };
@tracked isLoaded = false;
get aggregateNodeResources() {
return this.args.nodes.mapBy('resources');
}
get aggregatedAllocationResources() { get aggregatedAllocationResources() {
return this.scheduledAllocations.mapBy('resources').reduce( return this.scheduledAllocations.reduce(
(totals, allocation) => { (totals, allocation) => {
totals.cpu += allocation.cpu; totals.cpu += allocation.cpu;
totals.memory += allocation.memory; totals.memory += allocation.memory;
@ -24,15 +18,13 @@ export default class TopoVizNode extends Component {
} }
@action @action
async loadAllocations() { loadAllocations() {
await RSVP.all(this.args.nodes.mapBy('allocations')); this.scheduledAllocations = this.args.datacenter.nodes.reduce(
(all, node) => all.concat(node.allocations.filterBy('allocation.isScheduled')),
this.scheduledAllocations = this.args.nodes.reduce(
(all, node) => all.concat(node.allocations.filterBy('isScheduled')),
[] []
); );
this.aggregatedNodeResources = this.args.nodes.mapBy('resources').reduce( this.aggregatedNodeResources = this.args.datacenter.nodes.reduce(
(totals, node) => { (totals, node) => {
totals.cpu += node.cpu; totals.cpu += node.cpu;
totals.memory += node.memory; totals.memory += node.memory;
@ -41,7 +33,6 @@ export default class TopoVizNode extends Component {
{ cpu: 0, memory: 0 } { cpu: 0, memory: 0 }
); );
this.isLoaded = true;
this.args.onLoad && this.args.onLoad(); this.args.onLoad && this.args.onLoad();
} }
} }

View File

@ -1,6 +1,6 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object'; import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals'; import { guidFor } from '@ember/object/internals';
export default class TopoVizNode extends Component { export default class TopoVizNode extends Component {
@ -10,7 +10,7 @@ export default class TopoVizNode extends Component {
@tracked activeAllocation = null; @tracked activeAllocation = null;
get height() { get height() {
return this.args.heightScale ? this.args.heightScale(this.args.node.resources.memory) : 15; return this.args.heightScale ? this.args.heightScale(this.args.node.memory) : 15;
} }
get labelHeight() { get labelHeight() {
@ -51,19 +51,15 @@ export default class TopoVizNode extends Component {
} }
get count() { get count() {
return this.args.node.get('allocations.length'); return this.args.node.allocations.length;
} }
get allocations() { get allocations() {
// return this.args.node.allocations.filterBy('isScheduled').sortBy('resources.memory');
const totalCPU = this.args.node.resources.cpu;
const totalMemory = this.args.node.resources.memory;
// Sort by the delta between memory and cpu percent. This creates the least amount of // Sort by the delta between memory and cpu percent. This creates the least amount of
// drift between the positional alignment of an alloc's cpu and memory representations. // drift between the positional alignment of an alloc's cpu and memory representations.
return this.args.node.allocations.filterBy('isScheduled').sort((a, b) => { return this.args.node.allocations.filterBy('allocation.isScheduled').sort((a, b) => {
const deltaA = Math.abs(a.resources.memory / totalMemory - a.resources.cpu / totalCPU); const deltaA = Math.abs(a.memoryPercent - a.cpuPercent);
const deltaB = Math.abs(b.resources.memory / totalMemory - b.resources.cpu / totalCPU); const deltaB = Math.abs(b.memoryPercent - b.cpuPercent);
return deltaA - deltaB; return deltaA - deltaB;
}); });
} }
@ -89,8 +85,6 @@ export default class TopoVizNode extends Component {
if (newWidth !== this.dimensionsWidth) { if (newWidth !== this.dimensionsWidth) {
this.dimensionsWidth = newWidth; this.dimensionsWidth = newWidth;
this.data = this.computeData(this.dimensionsWidth); this.data = this.computeData(this.dimensionsWidth);
} else {
this.data = this.setSelection();
} }
} }
@ -110,51 +104,23 @@ export default class TopoVizNode extends Component {
} }
containsActiveTaskGroup() { containsActiveTaskGroup() {
return this.allocations.some( return this.args.node.allocations.some(
allocation => allocation =>
allocation.taskGroupName === this.args.activeTaskGroup && allocation.taskGroupName === this.args.activeTaskGroup &&
allocation.belongsTo('job').id() === this.args.activeJobId allocation.belongsTo('job').id() === this.args.activeJobId
); );
} }
setSelection() {
this.data.cpu.forEach(cpu => {
set(
cpu,
'isSelected',
cpu.allocation.taskGroupName === this.args.activeTaskGroup &&
cpu.allocation.belongsTo('job').id() === this.args.activeJobId
);
});
this.data.memory.forEach(memory => {
set(
memory,
'isSelected',
memory.allocation.taskGroupName === this.args.activeTaskGroup &&
memory.allocation.belongsTo('job').id() === this.args.activeJobId
);
});
return this.data;
}
computeData(width) { computeData(width) {
// TODO: differentiate reserved and resources const allocations = this.allocations;
if (!this.args.node.resources) return;
const totalCPU = this.args.node.resources.cpu;
const totalMemory = this.args.node.resources.memory;
let cpuOffset = 0; let cpuOffset = 0;
let memoryOffset = 0; let memoryOffset = 0;
const cpu = []; const cpu = [];
const memory = []; const memory = [];
for (const allocation of this.allocations) { for (const allocation of allocations) {
const cpuPercent = allocation.resources.cpu / totalCPU; const { cpuPercent, memoryPercent, isSelected } = allocation;
const memoryPercent = allocation.resources.memory / totalMemory; const isFirst = allocation === allocations[0];
const isFirst = allocation === this.allocations[0];
const isSelected =
allocation.taskGroupName === this.args.activeTaskGroup &&
allocation.belongsTo('job').id() === this.args.activeJobId;
let cpuWidth = cpuPercent * width - 1; let cpuWidth = cpuPercent * width - 1;
let memoryWidth = memoryPercent * width - 1; let memoryWidth = memoryPercent * width - 1;
@ -169,21 +135,19 @@ export default class TopoVizNode extends Component {
cpu.push({ cpu.push({
allocation, allocation,
isSelected,
offset: cpuOffset * 100, offset: cpuOffset * 100,
percent: cpuPercent * 100, percent: cpuPercent * 100,
width: cpuWidth, width: cpuWidth,
x: cpuOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0), x: cpuOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
className: allocation.clientStatus, className: allocation.allocation.clientStatus,
}); });
memory.push({ memory.push({
allocation, allocation,
isSelected,
offset: memoryOffset * 100, offset: memoryOffset * 100,
percent: memoryPercent * 100, percent: memoryPercent * 100,
width: memoryWidth, width: memoryWidth,
x: memoryOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0), x: memoryOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
className: allocation.clientStatus, className: allocation.allocation.clientStatus,
}); });
cpuOffset += cpuPercent; cpuOffset += cpuPercent;
@ -209,7 +173,3 @@ export default class TopoVizNode extends Component {
}; };
} }
} }
// capture width on did insert element
// update width on window resize
// recompute data when width changes

View File

@ -1,13 +1,12 @@
<div class="topo-viz {{if (eq this.datacenters.length 1) "is-single-column"}}" {{did-insert this.loadNodes}} {{did-insert this.captureElement}}> <div class="topo-viz {{if (eq this.datacenters.length 1) "is-single-column"}}" {{did-insert (perform this.buildTopology)}} {{did-insert this.captureElement}}>
{{#if this.isLoaded}} {{#if this.buildTopology.isRunning}}
{{#each this.datacenters as |dc|}} <div class="has-text-centered"><LoadingSpinner /></div>
{{else}}
{{#each this.topology.datacenters as |dc|}}
<TopoViz::Datacenter <TopoViz::Datacenter
@datacenter={{dc.name}} @datacenter={{dc}}
@nodes={{dc.nodes}} @heightScale={{this.topology.heightScale}}
@heightScale={{this.heightScale}}
@onAllocationSelect={{this.associateAllocations}} @onAllocationSelect={{this.associateAllocations}}
@activeTaskGroup={{this.activeTaskGroup}}
@activeJobId={{this.activeJobId}}
@onLoad={{action this.masonry}}/> @onLoad={{action this.masonry}}/>
{{/each}} {{/each}}

View File

@ -1,23 +1,19 @@
<div class="boxed-section topo-viz-datacenter" {{did-insert this.loadAllocations}}> <div class="boxed-section topo-viz-datacenter" {{did-insert this.loadAllocations}}>
<div class="masonry-container"> <div class="masonry-container">
<div class="boxed-section-head is-hollow"> <div class="boxed-section-head is-hollow">
<strong>{{@datacenter}}</strong> <strong>{{@datacenter.name}}</strong>
<span class="bumper-left">{{this.scheduledAllocations.length}} Allocs</span> <span class="bumper-left">{{this.scheduledAllocations.length}} Allocs</span>
<span class="bumper-left">{{@nodes.length}} Nodes</span> <span class="bumper-left">{{@datacenter.nodes.length}} Nodes</span>
<span class="bumper-left is-faded">{{this.aggregatedAllocationResources.memory}}/{{this.aggregatedNodeResources.memory}} MiB, <span class="bumper-left is-faded">{{this.aggregatedAllocationResources.memory}}/{{this.aggregatedNodeResources.memory}} MiB,
{{this.aggregatedAllocationResources.cpu}}/{{this.aggregatedNodeResources.cpu}} Mhz</span> {{this.aggregatedAllocationResources.cpu}}/{{this.aggregatedNodeResources.cpu}} Mhz</span>
</div> </div>
<div class="boxed-section-body"> <div class="boxed-section-body">
{{#if this.isLoaded}} {{#each @datacenter.nodes as |node|}}
{{#each @nodes as |node|}} <TopoViz::Node
<TopoViz::Node @node={{node}}
@node={{node}} @heightScale={{@heightScale}}
@heightScale={{@heightScale}} @onAllocationSelect={{@onAllocationSelect}} />
@onAllocationSelect={{@onAllocationSelect}} {{/each}}
@activeTaskGroup={{@activeTaskGroup}}
@activeJobId={{@activeJobId}} />
{{/each}}
{{/if}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
<div class="chart topo-viz-node {{unless this.allocations.length "is-empty"}}" {{did-insert this.reloadNode}}> <div class="chart topo-viz-node {{unless this.allocations.length "is-empty"}}" {{did-insert this.reloadNode}}>
<p> <p>
<strong>{{@node.name}}</strong> <strong>{{@node.node.name}}</strong>
<span class="bumper-left">{{this.count}} Allocs</span> <span class="bumper-left">{{this.count}} Allocs</span>
<span class="bumper-left is-faded">{{@node.resources.memory}} MiB, {{@node.resources.cpu}} Mhz</span> <span class="bumper-left is-faded">{{@node.memory}} MiB, {{@node.cpu}} Mhz</span>
</p> </p>
<svg class="chart" height="{{this.totalHeight}}px" {{did-insert this.render}} {{did-update this.updateRender @activeTaskGroup @activeJobId}} {{window-resize this.render}}> <svg class="chart" height="{{this.totalHeight}}px" {{did-insert this.render}} {{did-update this.updateRender}} {{window-resize this.render}}>
<defs> <defs>
<clipPath id="{{this.maskId}}"> <clipPath id="{{this.maskId}}">
<rect class="mask" x="0" y="0" width="{{this.dimensionsWidth}}px" height="{{this.maskHeight}}px" rx="2px" ry="2px" /> <rect class="mask" x="0" y="0" width="{{this.dimensionsWidth}}px" height="{{this.maskHeight}}px" rx="2px" ry="2px" />
@ -33,23 +33,23 @@
{{/if}} {{/if}}
{{#each this.data.memory key="allocation.id" as |memory|}} {{#each this.data.memory key="allocation.id" as |memory|}}
<g <g
class="bar {{memory.className}} {{if (eq this.activeAllocation memory.allocation) "is-active"}} {{if memory.isSelected "is-selected"}}" class="bar {{memory.className}} {{if (eq this.activeAllocation memory.allocation) "is-active"}} {{if memory.allocation.isSelected "is-selected"}}"
clip-path="url(#{{this.maskId}})" clip-path="url(#{{this.maskId}})"
data-allocation-id="{{memory.allocation.id}}" data-allocation-id="{{memory.allocation.allocation.id}}"
{{on "mouseenter" (fn this.highlightAllocation memory.allocation)}} {{on "mouseenter" (fn this.highlightAllocation memory.allocation)}}
{{on "click" (fn this.selectAllocation memory.allocation)}}> {{on "click" (fn this.selectAllocation memory.allocation)}}>
<rect <rect
width="{{memory.width}}px" width="{{memory.width}}px"
height="{{if memory.isSelected this.selectedHeight this.height}}px" height="{{if memory.allocation.isSelected this.selectedHeight this.height}}px"
x="{{memory.x}}px" x="{{memory.x}}px"
y="{{if memory.isSelected 0.5 0}}px" y="{{if memory.allocation.isSelected 0.5 0}}px"
class="layer-0" /> class="layer-0" />
{{#if (or (eq memory.className "starting") (eq memory.className "pending"))}} {{#if (or (eq memory.className "starting") (eq memory.className "pending"))}}
<rect <rect
width="{{memory.width}}px" width="{{memory.width}}px"
height="{{if memory.isSelected this.selectedHeight this.height}}px" height="{{if memory.allocation.isSelected this.selectedHeight this.height}}px"
x="{{memory.x}}px" x="{{memory.x}}px"
y="{{if memory.isSelected 0.5 0}}px" y="{{if memory.allocation.isSelected 0.5 0}}px"
class="layer-1" /> class="layer-1" />
{{/if}} {{/if}}
</g> </g>
@ -69,23 +69,23 @@
{{/if}} {{/if}}
{{#each this.data.cpu key="allocation.id" as |cpu|}} {{#each this.data.cpu key="allocation.id" as |cpu|}}
<g <g
class="bar {{cpu.className}} {{if (eq this.activeAllocation cpu.allocation) "is-active"}} {{if cpu.isSelected "is-selected"}}" class="bar {{cpu.className}} {{if (eq this.activeAllocation cpu.allocation) "is-active"}} {{if cpu.allocation.isSelected "is-selected"}}"
clip-path="url(#{{this.maskId}})" clip-path="url(#{{this.maskId}})"
data-allocation-id="{{cpu.allocation.id}}" data-allocation-id="{{cpu.allocation.allocation.id}}"
{{on "mouseenter" (fn this.highlightAllocation cpu.allocation)}} {{on "mouseenter" (fn this.highlightAllocation cpu.allocation)}}
{{on "click" (fn this.selectAllocation cpu.allocation)}}> {{on "click" (fn this.selectAllocation cpu.allocation)}}>
<rect <rect
width="{{cpu.width}}px" width="{{cpu.width}}px"
height="{{if cpu.isSelected this.selectedHeight this.height}}px" height="{{if cpu.allocation.isSelected this.selectedHeight this.height}}px"
x="{{cpu.x}}px" x="{{cpu.x}}px"
y="{{if cpu.isSelected this.selectedYOffset this.yOffset}}px" y="{{if cpu.allocation.isSelected this.selectedYOffset this.yOffset}}px"
class="layer-0" /> class="layer-0" />
{{#if (or (eq cpu.className "starting") (eq cpu.className "pending"))}} {{#if (or (eq cpu.className "starting") (eq cpu.className "pending"))}}
<rect <rect
width="{{cpu.width}}px" width="{{cpu.width}}px"
height="{{if cpu.isSelected this.selectedHeight this.height}}px" height="{{if cpu.allocation.isSelected this.selectedHeight this.height}}px"
x="{{cpu.x}}px" x="{{cpu.x}}px"
y="{{if cpu.isSelected this.selectedYOffset this.yOffset}}px" y="{{if cpu.allocation.isSelected this.selectedYOffset this.yOffset}}px"
class="layer-1" /> class="layer-1" />
{{/if}} {{/if}}
</g> </g>